diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6aa5fe..4535849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,6 @@ jobs: - name: Run lint run: yarn lint + + - name: Run tests + run: yarn test diff --git a/package.json b/package.json index 301862f..610ccee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "azdataGraph", "description": "azdataGraph is a derivative of mxGraph, which is a fully client side JavaScript diagramming library that uses SVG and HTML for rendering.", - "version": "0.0.139", + "version": "0.0.140", "homepage": "https://github.com/microsoft/azdataGraph", "author": "Microsoft", "license": "Apache-2.0", @@ -19,7 +19,8 @@ "build": "yarn compile-mxgraph && npx tsc && node build.js", "watch": "yarn compile-mxgraph && node build.js --watch", "examples": "http-server -p 4000", - "lint": "eslint" + "lint": "eslint", + "test": "node --test test/*.test.js" }, "devDependencies": { "@jgoz/esbuild-plugin-typecheck": "^4.0.2", diff --git a/src/js/azdata/view/azDataGraph.js b/src/js/azdata/view/azDataGraph.js index 3b82259..45cf5e2 100644 --- a/src/js/azdata/view/azDataGraph.js +++ b/src/js/azdata/view/azDataGraph.js @@ -87,6 +87,31 @@ azdataGraph.prototype.insertWeightedInvertedEdge = function (parent, id, value, return this.insertInvertedEdge(parent, id, value, source, target, `strokeWidth=${value.weight.toFixed(1)};` + style); }; +/** + * Function: escapeTooltipText + * + * Encodes text for safe insertion into tooltip HTML. + * + * Parameters: + * value - Value to encode. + */ +azdataGraph.prototype.escapeTooltipText = function (value) { + return String(value ?? '').replace(/[&<>"']/g, character => { + switch (character) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + } + }); +}; + /** * Function: getStyledTooltipForCell * @@ -113,10 +138,10 @@ azdataGraph.prototype.getStyledTooltipForCell = function (cell) { // tooltip heading for vertices only if (!cell.edge) { - let tooltipTitle = this.truncateTooltipTitle(cell.value.tooltipTitle); + let tooltipTitle = this.escapeTooltipText(this.truncateTooltipTitle(cell.value.tooltipTitle)); tooltip += `
${tooltipTitle}
`; if (cell.value.description) { - tooltip += `
${cell.value.description}
`; + tooltip += `
${this.escapeTooltipText(cell.value.description)}
`; } } @@ -129,8 +154,8 @@ azdataGraph.prototype.getStyledTooltipForCell = function (cell) { tooltip += `
`; tooltip += `
`; - tooltip += `${cell.value.metrics[i].name}`; - tooltip += `${cell.value.metrics[i].value}`; + tooltip += `${this.escapeTooltipText(cell.value.metrics[i].name)}`; + tooltip += `${this.escapeTooltipText(cell.value.metrics[i].value)}`; tooltip += '
'; if (i < cell.value.metrics.length - 1) { @@ -144,13 +169,13 @@ azdataGraph.prototype.getStyledTooltipForCell = function (cell) { if (!cell.edge) { cell.value.metrics.filter(m => m.isLongString).forEach(m => { tooltip += '
'; - tooltip += `
${m.name}
`; + tooltip += `
${this.escapeTooltipText(m.name)}
`; let metricLabel = m.value.replace(/(\r\n|\n|\r)/gm, " "); if (metricLabel.length > 103) { metricLabel = metricLabel.substring(0, 100) + '...'; } - tooltip += `
${metricLabel}
`; // Removing all line breaks as they look bad in tooltips + tooltip += `
${this.escapeTooltipText(metricLabel)}
`; // Removing all line breaks as they look bad in tooltips }) } diff --git a/test/tooltipRendering.test.js b/test/tooltipRendering.test.js new file mode 100644 index 0000000..9c40694 --- /dev/null +++ b/test/tooltipRendering.test.js @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +function loadAzdataGraph() { + function mxGraph() {} + mxGraph.prototype.getTooltipForCell = function () { + return ''; + }; + + const context = vm.createContext({ mxGraph }); + const source = fs.readFileSync( + path.join(__dirname, '..', 'src', 'js', 'azdata', 'view', 'azDataGraph.js'), + 'utf8' + ); + vm.runInContext(source, context); + return context.azdataGraph; +} + +test('escapeTooltipText encodes HTML-significant characters', () => { + const AzdataGraph = loadAzdataGraph(); + const graph = Object.create(AzdataGraph.prototype); + + assert.equal( + graph.escapeTooltipText(`&<>"'`), + '&<>"'' + ); +}); + +test('styled tooltips encode all dynamic text fields', () => { + const AzdataGraph = loadAzdataGraph(); + const graph = Object.create(AzdataGraph.prototype); + const tooltip = graph.getStyledTooltipForCell({ + edge: false, + value: { + tooltipTitle: 'Title & text', + description: '', + metrics: [ + { + name: 'Footer', + value: '', + isLongString: false, + }, + { + name: '', + value: `"'`, + isLongString: false, + }, + { + name: '', + value: 'first line\n', + isLongString: true, + }, + ], + }, + }); + + assert.match(tooltip, /<b>Title & text<\/b>/); + assert.match(tooltip, /<img src=x onerror=alert\(1\)>/); + assert.match(tooltip, /<Metric & name>/); + assert.match(tooltip, /"<\/span><svg onload=alert\(1\)>'/); + assert.match(tooltip, /<Long metric>/); + assert.match(tooltip, /first line <script>alert\(1\)<\/script>/); + assert.doesNotMatch(tooltip, /