From d2b0b8eac52ad8b68639c6581a1ed174a593f564 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 14 Jun 2023 15:54:07 -0700 Subject: [PATCH] feat: make data tables support html (#24368) --- superset-frontend/package-lock.json | 101 +++++----------- .../packages/superset-ui-core/package.json | 3 +- .../superset-ui-core/src/utils/html.test.tsx | 113 ++++++++++++++++++ .../superset-ui-core/src/utils/html.tsx | 53 ++++++++ .../superset-ui-core/src/utils/index.ts | 1 + .../src/components/Tooltip.tsx | 27 +---- .../src/utils/formatValue.ts | 31 +---- .../DrillDetail/DrillDetailMenuItems.tsx | 21 +++- .../Chart/DrillDetail/DrillDetailPane.tsx | 1 + .../src/components/FilterableTable/index.tsx | 13 +- .../src/components/Table/VirtualTable.tsx | 17 ++- .../src/components/Table/index.tsx | 7 ++ .../src/components/TableCollection/index.tsx | 1 - .../components/DataTableControl/index.tsx | 5 + .../DataTablesPane/components/SamplesPane.tsx | 2 + .../components/SingleQueryResultPane.tsx | 2 + 16 files changed, 267 insertions(+), 131 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx create mode 100644 superset-frontend/packages/superset-ui-core/src/utils/html.tsx diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 736890dc72f28..137695f4f888d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -23165,8 +23165,7 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", - "optional": true, - "peer": true, + "devOptional": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -23182,8 +23181,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "optional": true, - "peer": true + "devOptional": true }, "node_modules/ajv-keywords": { "version": "3.5.2", @@ -23751,7 +23749,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/aphrodite/-/aphrodite-1.2.5.tgz", "integrity": "sha1-g1jDbIC7A67puXFlqqcBhiJbSYM=", - "peer": true, "dependencies": { "asap": "^2.0.3", "inline-style-prefixer": "^3.0.1", @@ -25351,8 +25348,7 @@ "node_modules/bowser": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz", - "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==", - "peer": true + "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==" }, "node_modules/boxen": { "version": "5.1.2", @@ -28248,7 +28244,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz", "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==", - "peer": true, "dependencies": { "hyphenate-style-name": "^1.0.2", "isobject": "^3.0.1" @@ -29915,23 +29910,6 @@ "topojson": "^1.6.19" } }, - "node_modules/datatables.net": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.11.3.tgz", - "integrity": "sha512-VMj5qEaTebpNurySkM6jy6sGpl+s6onPK8xJhYr296R/vUBnz1+id16NVqNf9z5aR076OGcpGHCuiTuy4E05oQ==", - "dependencies": { - "jquery": ">=1.7" - } - }, - "node_modules/datatables.net-bs": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/datatables.net-bs/-/datatables.net-bs-1.11.3.tgz", - "integrity": "sha512-Db1YwAhO0QAWQbZTsKriUrOInT66+xaA+fV616KTKpQt5Zt+p6OsEKK+xv8LxLgG8qu5dPwMBlkhqSiS/hV2sg==", - "dependencies": { - "datatables.net": ">=1.10.25", - "jquery": ">=1.7" - } - }, "node_modules/date-fns": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", @@ -36621,8 +36599,7 @@ "node_modules/hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "peer": true + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -37056,7 +37033,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz", "integrity": "sha1-hVG45bTVcyROZqNLBPfTIHaitTQ=", - "peer": true, "dependencies": { "bowser": "^1.7.3", "css-in-js-utils": "^2.0.0" @@ -54244,8 +54220,7 @@ "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", - "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=", - "peer": true + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=" }, "node_modules/string-length": { "version": "4.0.1", @@ -58989,9 +58964,9 @@ } }, "node_modules/xss": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.10.tgz", - "integrity": "sha512-qmoqrRksmzqSKvgqzN0055UFWY7OKx1/9JWeRswwEVX9fCG5jcYRxa/A2DHcmZX6VJvjzHRQ2STeeVcQkrmLSw==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.14.tgz", + "integrity": "sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw==", "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" @@ -60278,7 +60253,8 @@ "reselect": "^4.0.0", "rison": "^0.1.1", "seedrandom": "^3.0.5", - "whatwg-fetch": "^3.0.0" + "whatwg-fetch": "^3.0.0", + "xss": "^1.0.14" }, "devDependencies": { "@emotion/styled": "^11.3.0", @@ -64620,6 +64596,7 @@ "@vx/scale": "0.0.140", "@vx/shape": "0.0.140", "@vx/tooltip": "0.0.140", + "aphrodite": "^1.2.0", "d3-array": "^1.2.0", "d3-format": "^1.2.0", "d3-selection": "^1.1.0", @@ -76494,7 +76471,8 @@ "resize-observer-polyfill": "1.5.1", "rison": "^0.1.1", "seedrandom": "^3.0.5", - "whatwg-fetch": "^3.0.0" + "whatwg-fetch": "^3.0.0", + "xss": "^1.0.14" }, "dependencies": { "@testing-library/react-hooks": { @@ -80287,13 +80265,15 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "devOptional": true, - "requires": {}, + "requires": { + "ajv": "^8.0.0" + }, "dependencies": { "ajv": { - "version": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", - "optional": true, - "peer": true, + "devOptional": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -80305,8 +80285,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "optional": true, - "peer": true + "devOptional": true } } }, @@ -80737,7 +80716,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/aphrodite/-/aphrodite-1.2.5.tgz", "integrity": "sha1-g1jDbIC7A67puXFlqqcBhiJbSYM=", - "peer": true, "requires": { "asap": "^2.0.3", "inline-style-prefixer": "^3.0.1", @@ -81973,8 +81951,7 @@ "bowser": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz", - "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==", - "peer": true + "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==" }, "boxen": { "version": "5.1.2", @@ -84274,7 +84251,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz", "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==", - "peer": true, "requires": { "hyphenate-style-name": "^1.0.2", "isobject": "^3.0.1" @@ -85501,23 +85477,6 @@ "topojson": "^1.6.19" } }, - "datatables.net": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.11.3.tgz", - "integrity": "sha512-VMj5qEaTebpNurySkM6jy6sGpl+s6onPK8xJhYr296R/vUBnz1+id16NVqNf9z5aR076OGcpGHCuiTuy4E05oQ==", - "requires": { - "jquery": ">=1.7" - } - }, - "datatables.net-bs": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/datatables.net-bs/-/datatables.net-bs-1.11.3.tgz", - "integrity": "sha512-Db1YwAhO0QAWQbZTsKriUrOInT66+xaA+fV616KTKpQt5Zt+p6OsEKK+xv8LxLgG8qu5dPwMBlkhqSiS/hV2sg==", - "requires": { - "datatables.net": ">=1.10.25", - "jquery": ">=1.7" - } - }, "date-fns": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", @@ -90666,8 +90625,7 @@ "hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", - "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", - "peer": true + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, "iconv-lite": { "version": "0.4.24", @@ -90985,7 +90943,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz", "integrity": "sha1-hVG45bTVcyROZqNLBPfTIHaitTQ=", - "peer": true, "requires": { "bowser": "^1.7.3", "css-in-js-utils": "^2.0.0" @@ -101381,7 +101338,8 @@ "integrity": "sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==", "requires": { "@babel/runtime": "^7.2.0", - "invariant": "^2.2.4" + "invariant": "^2.2.4", + "prop-types": "^15.5.7" } }, "react-split": { @@ -104185,8 +104143,7 @@ "string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", - "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=", - "peer": true + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=" }, "string-length": { "version": "4.0.1", @@ -107735,9 +107692,9 @@ "dev": true }, "xss": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.10.tgz", - "integrity": "sha512-qmoqrRksmzqSKvgqzN0055UFWY7OKx1/9JWeRswwEVX9fCG5jcYRxa/A2DHcmZX6VJvjzHRQ2STeeVcQkrmLSw==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.14.tgz", + "integrity": "sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw==", "requires": { "commander": "^2.20.3", "cssfilter": "0.0.10" @@ -107976,6 +107933,8 @@ "is-scoped": "^2.1.0", "lodash": "^4.17.10", "log-symbols": "^4.0.0", + "mem-fs": "^1.2.0 || ^2.0.0", + "mem-fs-editor": "^8.1.2 || ^9.0.0", "minimatch": "^3.0.4", "npmlog": "^5.0.1", "p-queue": "^6.6.2", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 59894f716948f..62deb7709b652 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -60,7 +60,8 @@ "reselect": "^4.0.0", "rison": "^0.1.1", "seedrandom": "^3.0.5", - "whatwg-fetch": "^3.0.0" + "whatwg-fetch": "^3.0.0", + "xss": "^1.0.14" }, "devDependencies": { "@emotion/styled": "^11.3.0", diff --git a/superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx b/superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx new file mode 100644 index 0000000000000..8fd06cb6f8e7a --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { + sanitizeHtml, + isProbablyHTML, + sanitizeHtmlIfNeeded, + safeHtmlSpan, + removeHTMLTags, +} from './html'; + +describe('sanitizeHtml', () => { + test('should sanitize the HTML string', () => { + const htmlString = ''; + const sanitizedString = sanitizeHtml(htmlString); + expect(sanitizedString).not.toContain('script'); + }); +}); + +describe('isProbablyHTML', () => { + test('should return true if the text contains HTML tags', () => { + const htmlText = '
Some HTML content
'; + const isHTML = isProbablyHTML(htmlText); + expect(isHTML).toBe(true); + }); + + test('should return false if the text does not contain HTML tags', () => { + const plainText = 'Just a plain text'; + const isHTML = isProbablyHTML(plainText); + expect(isHTML).toBe(false); + }); +}); + +describe('sanitizeHtmlIfNeeded', () => { + test('should sanitize the HTML string if it contains HTML tags', () => { + const htmlString = '
Some HTML content
'; + const sanitizedString = sanitizeHtmlIfNeeded(htmlString); + expect(sanitizedString).toEqual(htmlString); + }); + + test('should return the string as is if it does not contain HTML tags', () => { + const plainText = 'Just a plain text'; + const sanitizedString = sanitizeHtmlIfNeeded(plainText); + expect(sanitizedString).toEqual(plainText); + }); +}); + +describe('safeHtmlSpan', () => { + test('should return a safe HTML span when the input is HTML', () => { + const htmlString = '
Some HTML content
'; + const safeSpan = safeHtmlSpan(htmlString); + expect(safeSpan).toEqual( + , + ); + }); + + test('should return the input string as is when it is not HTML', () => { + const plainText = 'Just a plain text'; + const result = safeHtmlSpan(plainText); + expect(result).toEqual(plainText); + }); +}); + +describe('removeHTMLTags', () => { + test('should remove HTML tags from the string', () => { + const input = '

Hello, World!

'; + const output = removeHTMLTags(input); + expect(output).toBe('Hello, World!'); + }); + + test('should return the same string when no HTML tags are present', () => { + const input = 'This is a plain text.'; + const output = removeHTMLTags(input); + expect(output).toBe('This is a plain text.'); + }); + + test('should remove nested HTML tags and return combined text content', () => { + const input = '

Title

Content

'; + const output = removeHTMLTags(input); + expect(output).toBe('TitleContent'); + }); + + test('should handle self-closing tags and return an empty string', () => { + const input = 'Image'; + const output = removeHTMLTags(input); + expect(output).toBe(''); + }); + + test('should handle malformed HTML tags and remove only well-formed tags', () => { + const input = '

Unclosed tag'; + const output = removeHTMLTags(input); + expect(output).toBe('Unclosed tag'); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/utils/html.tsx b/superset-frontend/packages/superset-ui-core/src/utils/html.tsx new file mode 100644 index 0000000000000..3215eb9b9de5b --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/utils/html.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { FilterXSS, getDefaultWhiteList } from 'xss'; + +const xssFilter = new FilterXSS({ + whiteList: { + ...getDefaultWhiteList(), + span: ['style', 'class', 'title'], + div: ['style', 'class'], + a: ['style', 'class', 'href', 'title', 'target'], + img: ['style', 'class', 'src', 'alt', 'title', 'width', 'height'], + video: [ + 'autoplay', + 'controls', + 'loop', + 'preload', + 'src', + 'height', + 'width', + 'muted', + ], + }, + stripIgnoreTag: true, + css: false, +}); + +export function sanitizeHtml(htmlString: string) { + return xssFilter.process(htmlString); +} + +export function isProbablyHTML(text: string) { + return /<[^>]+>/.test(text); +} + +export function sanitizeHtmlIfNeeded(htmlString: string) { + return isProbablyHTML(htmlString) ? sanitizeHtml(htmlString) : htmlString; +} + +export function safeHtmlSpan(possiblyHtmlString: string) { + const isHtml = isProbablyHTML(possiblyHtmlString); + if (isHtml) { + return ( + + ); + } + return possiblyHtmlString; +} + +export function removeHTMLTags(str: string): string { + return str.replace(/<[^>]*>/g, ''); +} diff --git a/superset-frontend/packages/superset-ui-core/src/utils/index.ts b/superset-frontend/packages/superset-ui-core/src/utils/index.ts index 4efc3dedb65af..32fa88251ee5f 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/index.ts @@ -31,3 +31,4 @@ export { getSelectedText } from './getSelectedText'; export * from './featureFlags'; export * from './random'; export * from './typedMemo'; +export * from './html'; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx index 9b20113448b7f..d61c4844acfe1 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/components/Tooltip.tsx @@ -17,9 +17,8 @@ * under the License. */ -import { styled } from '@superset-ui/core'; -import React, { useMemo } from 'react'; -import { filterXSS } from 'xss'; +import { styled, safeHtmlSpan } from '@superset-ui/core'; +import React from 'react'; export type TooltipProps = { tooltip: @@ -55,28 +54,12 @@ export default function Tooltip(props: TooltipProps) { } const { x, y, content } = tooltip; - - if (typeof content === 'string') { - // eslint-disable-next-line react-hooks/rules-of-hooks - const contentHtml = useMemo( - () => ({ - __html: filterXSS(content, { stripIgnoreTag: true }), - }), - [content], - ); - return ( - -
- - ); - } + const safeContent = + typeof content === 'string' ? safeHtmlSpan(content) : content; return ( - {content} + {safeContent} ); } diff --git a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts index 327e48ab3d89c..607afa8ac3989 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/utils/formatValue.ts @@ -16,41 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { FilterXSS, getDefaultWhiteList } from 'xss'; import { DataRecordValue, GenericDataType, getNumberFormatter, + isProbablyHTML, + sanitizeHtml, } from '@superset-ui/core'; import { DataColumnMeta } from '../types'; import DateWithFormatter from './DateWithFormatter'; -const xss = new FilterXSS({ - whiteList: { - ...getDefaultWhiteList(), - span: ['style', 'class', 'title'], - div: ['style', 'class'], - a: ['style', 'class', 'href', 'title', 'target'], - img: ['style', 'class', 'src', 'alt', 'title', 'width', 'height'], - video: [ - 'autoplay', - 'controls', - 'loop', - 'preload', - 'src', - 'height', - 'width', - 'muted', - ], - }, - stripIgnoreTag: true, - css: false, -}); - -function isProbablyHTML(text: string) { - return /<[^>]+>/.test(text); -} - /** * Format text for cell value. */ @@ -76,7 +51,7 @@ function formatValue( return [false, formatter(value as number)]; } if (typeof value === 'string') { - return isProbablyHTML(value) ? [true, xss.process(value)] : [false, value]; + return isProbablyHTML(value) ? [true, sanitizeHtml(value)] : [false, value]; } return [false, value.toString()]; } diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx index 98fe90eafae43..73f3a028e93e2 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailMenuItems.tsx @@ -26,6 +26,7 @@ import { extractQueryFields, getChartMetadataRegistry, QueryFormData, + removeHTMLTags, styled, t, } from '@superset-ui/core'; @@ -50,7 +51,21 @@ const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => ( ); -const Filter = styled.span` +const Filter = ({ + children, + stripHTML = false, +}: { + children: ReactNode; + stripHTML: boolean; +}) => { + const content = + stripHTML && typeof children === 'string' + ? removeHTMLTags(children) + : children; + return {content}; +}; + +const StyledFilter = styled(Filter)` ${({ theme }) => ` font-weight: ${theme.typography.weights.bold}; color: ${theme.colors.primary.base}; @@ -191,7 +206,7 @@ const DrillDetailMenuItems = ({ onClick={openModal.bind(null, [filter])} > {`${DRILL_TO_DETAIL_TEXT} `} - {filter.formattedVal} + {filter.formattedVal} ))} {filters.length > 1 && ( @@ -202,7 +217,7 @@ const DrillDetailMenuItems = ({ >
{`${DRILL_TO_DETAIL_TEXT} `} - {t('all')} + {t('all')}
)} diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index daf9f3f1cf4b5..d337e9b013afe 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -300,6 +300,7 @@ export default function DrillDetailPane({ } resizable virtualize + allowHTML /> ); diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx index 1cf880419db25..2ca38617fccbf 100644 --- a/superset-frontend/src/components/FilterableTable/index.tsx +++ b/superset-frontend/src/components/FilterableTable/index.tsx @@ -22,6 +22,7 @@ import { JSONTree } from 'react-json-tree'; import { getMultipleTextDimensions, t, + safeHtmlSpan, styled, useTheme, } from '@superset-ui/core'; @@ -120,6 +121,7 @@ export interface FilterableTableProps { // need antd 5.0 to support striped color pattern striped?: boolean; expandedColumns?: string[]; + allowHTML?: boolean; } const FilterableTable = ({ @@ -128,6 +130,7 @@ const FilterableTable = ({ height, filterText = '', expandedColumns = [], + allowHTML = true, }: FilterableTableProps) => { const formatTableData = (data: Record[]): Datum[] => data.map(row => { @@ -346,13 +349,17 @@ const FilterableTable = ({ const renderTableCell = (cellData: CellDataType, columnKey: string) => { const cellNode = getCellContent({ cellData, columnKey }); - const content = - cellData === null ? {cellNode} : cellNode; + if (cellData === null) { + return {cellNode}; + } const jsonObject = safeJsonObjectParse(cellData); if (jsonObject) { return renderJsonModal(cellNode, jsonObject, cellData); } - return content; + if (allowHTML && typeof cellData === 'string') { + return safeHtmlSpan(cellNode); + } + return cellNode; }; // exclude the height of the horizontal scroll bar from the height of the table diff --git a/superset-frontend/src/components/Table/VirtualTable.tsx b/superset-frontend/src/components/Table/VirtualTable.tsx index 721fd906b469d..d8658dde60997 100644 --- a/superset-frontend/src/components/Table/VirtualTable.tsx +++ b/superset-frontend/src/components/Table/VirtualTable.tsx @@ -25,12 +25,13 @@ import classNames from 'classnames'; import { useResizeDetector } from 'react-resize-detector'; import React, { useEffect, useRef, useState, useCallback } from 'react'; import { VariableSizeGrid as Grid } from 'react-window'; -import { useTheme, styled } from '@superset-ui/core'; +import { useTheme, styled, safeHtmlSpan } from '@superset-ui/core'; import { TableSize, ETableAction } from './index'; interface VirtualTableProps extends AntTableProps { height?: number; + allowHTML?: boolean; } const StyledCell = styled('div')<{ height?: number }>( @@ -71,7 +72,15 @@ const MIDDLE = 47; const VirtualTable = ( props: VirtualTableProps, ) => { - const { columns, pagination, onChange, height, scroll, size } = props; + const { + columns, + pagination, + onChange, + height, + scroll, + size, + allowHTML = false, + } = props; const [tableWidth, setTableWidth] = useState(0); const onResize = useCallback((width: number) => { setTableWidth(width); @@ -213,6 +222,10 @@ const VirtualTable = ( content = render(content, data, rowIndex); } + if (allowHTML && typeof content === 'string') { + content = safeHtmlSpan(content); + } + return ( { * Returns props that should be applied to each row component. */ onRow?: AntTableProps['onRow']; + /** + * Will render html safely if set to true, anchor tags and such. Currently + * only supported for virtualize == true + */ + allowHTML?: boolean; } const defaultRowSelection: React.Key[] = []; @@ -249,6 +254,7 @@ export function Table( onChange = noop, recordCount, onRow, + allowHTML = false, } = props; const wrapperRef = useRef(null); @@ -405,6 +411,7 @@ export function Table( scrollToFirstRowOnChange: false, }), }} + allowHTML={allowHTML} /> )}
diff --git a/superset-frontend/src/components/TableCollection/index.tsx b/superset-frontend/src/components/TableCollection/index.tsx index 88296edf638ee..bcda5139eb141 100644 --- a/superset-frontend/src/components/TableCollection/index.tsx +++ b/superset-frontend/src/components/TableCollection/index.tsx @@ -295,7 +295,6 @@ export default React.memo( const isWrapText = columnsForWrapText?.includes( cell.column.Header as string, ); - return ( }, + allowHTML?: boolean, ) => { const [originalFormattedTimeColumns, setOriginalFormattedTimeColumns] = useState(getTimeColumns(datasourceId)); @@ -346,6 +348,9 @@ export const useTableColumns = ( ) { return timeFormatter(value); } + if (typeof value === 'string' && allowHTML) { + return safeHtmlSpan(value); + } return String(value); }, ...moreConfigs?.[key], diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx index 5c66075750dc5..b542aad99643a 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -92,6 +92,8 @@ export const SamplesPane = ({ data, datasourceId, isVisible, + {}, // moreConfig + true, // allowHTML ); const filteredData = useFilteredTableData(filterText, data); diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx index 27d312cc3ccda..c2614dfda6ca6 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx @@ -44,6 +44,8 @@ export const SingleQueryResultPane = ({ data, datasourceId, isVisible, + {}, // moreConfig + true, // allowHTML ); const filteredData = useFilteredTableData(filterText, data);