diff --git a/.gitignore b/.gitignore
index b3ef8b6db115..1600a06ea3cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -126,3 +126,5 @@ docker/*local*
test-report.html
superset/static/stats/statistics.html
.aider*
+
+.cursor/
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 793ca61edfdd..48240a0b4888 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -321,7 +321,10 @@
"eslint-rules/eslint-plugin-icons": {
"version": "1.0.0",
"dev": true,
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "eslint": ">=0.8.0"
+ }
},
"eslint-rules/eslint-plugin-theme-colors": {
"version": "1.0.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
index 3f38e8c9c162..3c1e761d5632 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/utils/html.test.tsx
@@ -24,6 +24,7 @@ import {
removeHTMLTags,
isJsonString,
getParagraphContents,
+ escapeHtml,
} from './html';
describe('sanitizeHtml', () => {
@@ -189,3 +190,49 @@ describe('getParagraphContents', () => {
});
});
});
+
+describe('escapeHtml', () => {
+ test('should escape angle brackets', () => {
+ const input = '
test
';
+ const output = escapeHtml(input);
+ expect(output).toBe('<div>test</div>');
+ });
+
+ test('should escape ampersand', () => {
+ const input = 'A & B';
+ const output = escapeHtml(input);
+ expect(output).toBe('A & B');
+ });
+
+ test('should escape quotes', () => {
+ const input = 'Say "hello" and \'hi\'';
+ const output = escapeHtml(input);
+ expect(output).toBe('Say "hello" and 'hi'');
+ });
+
+ test('should escape all HTML entities in complex string', () => {
+ const input = '';
+ const output = escapeHtml(input);
+ expect(output).toBe(
+ '<script>alert("XSS & more")</script>',
+ );
+ });
+
+ test('should return plain text unchanged', () => {
+ const input = 'Just plain text';
+ const output = escapeHtml(input);
+ expect(output).toBe('Just plain text');
+ });
+
+ test('should handle strings with comparison operators', () => {
+ const input = 'a <= 10 and b > 10';
+ const output = escapeHtml(input);
+ expect(output).toBe('a <= 10 and b > 10');
+ });
+
+ test('should handle empty string', () => {
+ const input = '';
+ const output = escapeHtml(input);
+ expect(output).toBe('');
+ });
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/html.tsx b/superset-frontend/packages/superset-ui-core/src/utils/html.tsx
index 723c0dc3d968..ab283dbe3968 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/html.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/utils/html.tsx
@@ -87,6 +87,24 @@ export function removeHTMLTags(str: string): string {
return str.replace(/<[^>]*>/g, '');
}
+/**
+ * Escapes HTML entities in a string to prevent it from being interpreted as HTML.
+ * This is useful for displaying strings containing angle brackets as plain text.
+ *
+ * @param str - The string to escape
+ * @returns The escaped string with HTML entities
+ */
+export function escapeHtml(str: string): string {
+ const escapeMap: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ };
+ return str.replace(/[&<>"']/g, match => escapeMap[match]);
+}
+
export function isJsonString(str: string): boolean {
try {
JSON.parse(str);
diff --git a/superset-frontend/src/components/FilterableTable/utils.tsx b/superset-frontend/src/components/FilterableTable/utils.tsx
index a4e4a4c84450..30aa725138a6 100644
--- a/superset-frontend/src/components/FilterableTable/utils.tsx
+++ b/superset-frontend/src/components/FilterableTable/utils.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
import { JsonModal, safeJsonObjectParse } from 'src/components/JsonModal';
-import { t, safeHtmlSpan } from '@superset-ui/core';
+import { t, safeHtmlSpan, escapeHtml, isProbablyHTML } from '@superset-ui/core';
import { NULL_STRING, CellDataType } from './useCellContentParser';
type CellParams = {
@@ -51,7 +51,26 @@ export const renderResultCell = ({
/>
);
}
- if (allowHTML && typeof cellData === 'string') {
+ if (typeof cellData === 'string') {
+ // For SQL Lab query results, always display strings as text (not as HTML)
+ // Escape HTML entities so strings like 'test
' display correctly
+ if (!allowHTML) {
+ // When HTML is not allowed, always escape entities
+ // Wrap in span and use dangerouslySetInnerHTML to ensure proper rendering
+ return (
+
+ );
+ }
+ // When HTML is allowed, check if it looks like HTML
+ // If it does, escape it to display as text (SQL results should show actual values)
+ // If it doesn't, use safeHtmlSpan for potential HTML rendering
+ if (isProbablyHTML(cellNode)) {
+ // Escape HTML-like strings to display them as literal text
+ // Use dangerouslySetInnerHTML so the browser decodes the entities correctly
+ return (
+
+ );
+ }
return safeHtmlSpan(cellNode);
}
return cellNode;