This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch ATLAS-5370 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 33400ceb725260ecc643d60a9ee77ba2e682d1e6 Author: Prasad Pawar <[email protected]> AuthorDate: Mon Aug 10 13:04:23 2026 +0530 Atlas UI : Encode text in D3 graph tooltips Lineage and Profile charts --- .../src/Utils/__tests__/escapeHtml.test.ts | 258 +++++++++++++++++++++ .../views/Lineage/atlas-lineage/src/Utils/index.js | 10 +- .../src/views/Lineage/atlas-lineage/src/index.js | 12 +- .../js/external_lib/atlas-lineage/dist/index.js | 2 +- .../js/external_lib/atlas-lineage/src/index.js | 9 +- .../external_lib/atlas-lineage/webpack.config.js | 3 +- dashboardv2/public/js/migration.js | 2 +- .../public/js/views/graph/ProfileBarChart.js | 17 +- 8 files changed, 298 insertions(+), 15 deletions(-) diff --git a/dashboard/src/views/Lineage/atlas-lineage/src/Utils/__tests__/escapeHtml.test.ts b/dashboard/src/views/Lineage/atlas-lineage/src/Utils/__tests__/escapeHtml.test.ts new file mode 100644 index 000000000..6c55f0fc0 --- /dev/null +++ b/dashboard/src/views/Lineage/atlas-lineage/src/Utils/__tests__/escapeHtml.test.ts @@ -0,0 +1,258 @@ +/* + * 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. + */ + +jest.mock("../LineageUtils", () => ({ default: {} })); +jest.mock("../DataUtils", () => ({ default: {} })); + +import { escapeHtml } from "../index"; + +/** + * Mirrors underscore _.escape used in classic UI: + * - atlas-lineage (dashboardv2/external_lib) + * - ProfileBarChart.js + */ +const underscoreEscape = (value: unknown): string => { + if (value == null) { + return ""; + } + + const escapeMap: Record<string, string> = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "`": "`" + }; + + return String(value).replace(/[&<>"'`]/g, (match) => escapeMap[match]); +}; + +const expectSafeTooltipText = (encoded: string) => { + expect(encoded).not.toMatch(/<[^&!]/); + expect(encoded).not.toContain("<script"); + expect(encoded).not.toContain("<img"); +}; + +describe("lineage tooltip escapeHtml (React)", () => { + describe("HTML metacharacter encoding", () => { + it("escapes angle brackets in malicious tooltip labels", () => { + const payload = '<img src=x onerror="window.__xss=1">'; + const escaped = escapeHtml(payload); + + expect(escaped).not.toContain("<img"); + expect(escaped).toContain("<img"); + expect(escaped).toContain("""); + expectSafeTooltipText(escaped); + }); + + it("escapes script tags", () => { + const escaped = escapeHtml('<script>alert("x")</script>'); + expect(escaped).toBe("<script>alert("x")</script>"); + expectSafeTooltipText(escaped); + }); + + it("escapes ampersand", () => { + expect(escapeHtml("a & b")).toBe("a & b"); + }); + + it("double-encodes already-encoded ampersand entities safely", () => { + expect(escapeHtml("a & b")).toBe("a &amp; b"); + expectSafeTooltipText(escapeHtml("a & b")); + }); + + it("escapes single quotes", () => { + expect(escapeHtml("O'Brien")).toBe("O'Brien"); + }); + + it("escapes double quotes", () => { + expect(escapeHtml('say "hello"')).toBe("say "hello""); + }); + + it("escapes all special characters in one payload", () => { + const payload = `<a href='x'>&"test"`; + const escaped = escapeHtml(payload); + expect(escaped).toBe("<a href='x'>&"test""); + expectSafeTooltipText(escaped); + }); + }); + + describe("nullish and empty inputs", () => { + it("handles null and undefined values", () => { + expect(escapeHtml(null)).toBe(""); + expect(escapeHtml(undefined)).toBe(""); + }); + + it("handles empty string", () => { + expect(escapeHtml("")).toBe(""); + }); + + it("handles whitespace-only label used when displayText is missing", () => { + expect(escapeHtml(" ")).toBe(" "); + }); + }); + + describe("numeric and coerced values", () => { + it("coerces numeric values to string", () => { + expect(escapeHtml(123)).toBe("123"); + expect(escapeHtml(0)).toBe("0"); + }); + + it("coerces boolean values to string", () => { + expect(escapeHtml(true)).toBe("true"); + expect(escapeHtml(false)).toBe("false"); + }); + }); + + describe("plain text preservation", () => { + it("preserves safe entity display names", () => { + expect(escapeHtml("sales_db.customers")).toBe("sales_db.customers"); + }); + + it("preserves qualified names with dots and underscores", () => { + expect(escapeHtml("hive.db.table_v1")).toBe("hive.db.table_v1"); + }); + + it("preserves unicode characters in entity names", () => { + expect(escapeHtml("データ_テーブル")).toBe("データ_テーブル"); + }); + + it("preserves newlines in multiline query text", () => { + const queryText = "SELECT *\nFROM t\nWHERE id = 1"; + expect(escapeHtml(queryText)).toBe(queryText); + expectSafeTooltipText(escapeHtml(queryText)); + }); + }); + + describe("lineage tooltip field scenarios", () => { + it("encodes toolTipLabel from entity displayText", () => { + const label = 'db.table<script>alert(1)</script>'; + const escaped = escapeHtml(label); + expect(escaped).toBe("db.table<script>alert(1)</script>"); + expectSafeTooltipText(escaped); + }); + + it("encodes typeName values", () => { + const escaped = escapeHtml("hive_table<script>"); + expect(escaped).toBe("hive_table<script>"); + expectSafeTooltipText(escaped); + }); + + it("encodes queryText with SQL quotes and comparison operators", () => { + const queryText = + "SELECT * FROM t WHERE x < 1 AND name = \"foo\" AND y > 0"; + const escaped = escapeHtml(queryText); + + expect(escaped).toContain("<"); + expect(escaped).toContain(">"); + expect(escaped).toContain("""); + expectSafeTooltipText(escaped); + }); + + it("encodes static toolTipTitle values", () => { + expect(escapeHtml("Type")).toBe("Type"); + expect(escapeHtml('Type<script>')).toBe("Type<script>"); + }); + }); + + describe("intentional differences from classic _.escape", () => { + it("does not encode backtick in text content (safe inside span tags)", () => { + expect(escapeHtml("db`table")).toBe("db`table"); + expectSafeTooltipText(escapeHtml("db`table")); + }); + }); +}); + +describe("classic UI tooltip encoding (underscore _.escape parity)", () => { + const tooltipPayloads = [ + '<img src=x onerror="alert(1)">', + "O'Brien", + "a & b", + "sales_db.customers", + 123, + "", + null, + undefined + ]; + + it.each(tooltipPayloads)( + "underscoreEscape produces safe output for %p", + (payload) => { + const escaped = underscoreEscape(payload); + + if (payload == null) { + expect(escaped).toBe(""); + return; + } + + expectSafeTooltipText(escaped); + + const raw = String(payload); + if (/[&<>"'`]/.test(raw)) { + expect(escaped).not.toBe(raw); + } else { + expect(escaped).toBe(raw); + } + } + ); + + it("handles null and undefined like production _.escape", () => { + expect(underscoreEscape(null)).toBe(""); + expect(underscoreEscape(undefined)).toBe(""); + }); + + it("preserves numeric count values for profile bar chart tooltips", () => { + expect(underscoreEscape(42)).toBe("42"); + expect(underscoreEscape(0)).toBe("0"); + }); + + it("encodes lineage queryText with quotes and angle brackets", () => { + const queryText = 'SELECT * FROM t WHERE x < 1 AND name = "foo"'; + const escaped = underscoreEscape(queryText); + + expect(escaped).toContain("<"); + expect(escaped).toContain("""); + expectSafeTooltipText(escaped); + }); + + it("encodes lineage typeName values", () => { + const escaped = underscoreEscape("hive_table<script>"); + expect(escaped).toBe("hive_table<script>"); + expectSafeTooltipText(escaped); + }); + + it("encodes backtick characters used in entity names", () => { + expect(underscoreEscape("db`table")).toBe("db`table"); + }); + + it("react escapeHtml and classic _.escape both produce safe tooltip output", () => { + const payloads = [ + '<img src=x onerror="alert(1)">', + '<script>alert(1)</script>', + "O'Brien", + "a & b", + "hive_process", + 'SELECT * FROM t WHERE x < 1 AND name = "foo"', + "hive_table<script>" + ]; + + payloads.forEach((payload) => { + expectSafeTooltipText(escapeHtml(payload)); + expectSafeTooltipText(underscoreEscape(payload)); + }); + }); +}); diff --git a/dashboard/src/views/Lineage/atlas-lineage/src/Utils/index.js b/dashboard/src/views/Lineage/atlas-lineage/src/Utils/index.js index 4a146ef2f..21d9f61dc 100644 --- a/dashboard/src/views/Lineage/atlas-lineage/src/Utils/index.js +++ b/dashboard/src/views/Lineage/atlas-lineage/src/Utils/index.js @@ -25,10 +25,18 @@ const isEmpty = (value) => (typeof value === "object" && Object.keys(value).length === 0) || (typeof value === "string" && value.trim().length === 0); +const escapeHtml = (value) => + String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + String.prototype.trunc = String.prototype.trunc || function(n) { return this.length > n ? this.substr(0, n - 1) + "..." : this; }; -export { LineageUtils, DataUtils, isEmpty }; \ No newline at end of file +export { LineageUtils, DataUtils, isEmpty, escapeHtml }; \ No newline at end of file diff --git a/dashboard/src/views/Lineage/atlas-lineage/src/index.js b/dashboard/src/views/Lineage/atlas-lineage/src/index.js index 8703401ee..8bd272c5c 100644 --- a/dashboard/src/views/Lineage/atlas-lineage/src/index.js +++ b/dashboard/src/views/Lineage/atlas-lineage/src/index.js @@ -19,7 +19,7 @@ import dagreD3 from "dagre-d3"; import { select, selection, event } from "d3-selection"; import { curveBasis } from "d3-shape"; -import { LineageUtils, DataUtils, isEmpty } from "./Utils"; +import { LineageUtils, DataUtils, isEmpty, escapeHtml } from "./Utils"; import d3Tip from "d3-tip"; import "./styles/style.scss"; @@ -543,7 +543,9 @@ export default class LineageHelper { var htmlStr = ""; if (toolTipTitle) { htmlStr = - "<h5 style='text-align: center;'>" + toolTipTitle + "</h5>"; + "<h5 style='text-align: center;'>" + + escapeHtml(toolTipTitle) + + "</h5>"; } else if (value.id !== this.guid) { htmlStr = "<h5 style='text-align: center;'>" + @@ -553,18 +555,18 @@ export default class LineageHelper { htmlStr += "<h5 class='text-center'><span style='color:#359f89'>" + - value.toolTipLabel + + escapeHtml(value.toolTipLabel) + "</span></h5> "; if (value.typeName) { htmlStr += "<h5 class='text-center'><span>(" + - value.typeName + + escapeHtml(value.typeName) + ")</span></h5> "; } if (value.queryText) { htmlStr += "<h5>Query: <span style='color:#359f89'>" + - value.queryText + + escapeHtml(value.queryText) + "</span></h5> "; } return "<div class='tip-inner-scroll'>" + htmlStr + "</div>"; diff --git a/dashboardv2/public/js/external_lib/atlas-lineage/dist/index.js b/dashboardv2/public/js/external_lib/atlas-lineage/dist/index.js index 40dc81b58..daf2597d1 100644 --- a/dashboardv2/public/js/external_lib/atlas-lineage/dist/index.js +++ b/dashboardv2/public/js/external_lib/atlas-lineage/dist/index.js @@ -1 +1 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("platform"),require("dagreD3")):"function"==typeof define&&define.amd?define(["platform","dagreD3"],e):"object"==typeof exports?exports.LineageHelper=e(require("platform"),require("dagreD3")):t.LineageHelper=e(t.platform,t.dagreD3)}(window,(function(t,e){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,n),i. [...] \ No newline at end of file +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("_"),require("platform"),require("dagreD3")):"function"==typeof define&&define.amd?define(["_","platform","dagreD3"],e):"object"==typeof exports?exports.LineageHelper=e(require("_"),require("platform"),require("dagreD3")):t.LineageHelper=e(t._,t.platform,t.dagreD3)}(window,(function(t,e,n){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t [...] \ No newline at end of file diff --git a/dashboardv2/public/js/external_lib/atlas-lineage/src/index.js b/dashboardv2/public/js/external_lib/atlas-lineage/src/index.js index 9621bbd1d..50249004b 100644 --- a/dashboardv2/public/js/external_lib/atlas-lineage/src/index.js +++ b/dashboardv2/public/js/external_lib/atlas-lineage/src/index.js @@ -21,6 +21,7 @@ import { select, selection, event } from "d3-selection"; import { curveBasis } from "d3-shape"; import { LineageUtils, DataUtils } from "./Utils"; import d3Tip from "d3-tip"; +import _ from "underscore"; import "./styles/style.scss"; @@ -507,17 +508,17 @@ export default class LineageHelper { var value = g.node(d); var htmlStr = ""; if (toolTipTitle) { - htmlStr = "<h5 style='text-align: center;'>" + toolTipTitle + "</h5>"; + htmlStr = "<h5 style='text-align: center;'>" + _.escape(toolTipTitle) + "</h5>"; } else if (value.id !== this.guid) { htmlStr = "<h5 style='text-align: center;'>" + (value.isLineage ? "Lineage" : "Impact") + "</h5>"; } - htmlStr += "<h5 class='text-center'><span style='color:#359f89'>" + value.toolTipLabel + "</span></h5> "; + htmlStr += "<h5 class='text-center'><span style='color:#359f89'>" + _.escape(value.toolTipLabel) + "</span></h5> "; if (value.typeName) { - htmlStr += "<h5 class='text-center'><span>(" + value.typeName + ")</span></h5> "; + htmlStr += "<h5 class='text-center'><span>(" + _.escape(value.typeName) + ")</span></h5> "; } if (value.queryText) { - htmlStr += "<h5>Query: <span style='color:#359f89'>" + value.queryText + "</span></h5> "; + htmlStr += "<h5>Query: <span style='color:#359f89'>" + _.escape(value.queryText) + "</span></h5> "; } return "<div class='tip-inner-scroll'>" + htmlStr + "</div>"; } diff --git a/dashboardv2/public/js/external_lib/atlas-lineage/webpack.config.js b/dashboardv2/public/js/external_lib/atlas-lineage/webpack.config.js index 9457b386d..ac71e17bf 100644 --- a/dashboardv2/public/js/external_lib/atlas-lineage/webpack.config.js +++ b/dashboardv2/public/js/external_lib/atlas-lineage/webpack.config.js @@ -40,7 +40,8 @@ const config = { "dagre-d3": "dagreD3", platform: "platform", dagre: "dagre", - graphlib: "graphlib" + graphlib: "graphlib", + underscore: "_" }, module: { rules: [ diff --git a/dashboardv2/public/js/migration.js b/dashboardv2/public/js/migration.js index 451113105..8ad62561b 100644 --- a/dashboardv2/public/js/migration.js +++ b/dashboardv2/public/js/migration.js @@ -95,7 +95,7 @@ require.config({ 'exports': ['d3-tip'] }, 'LineageHelper': { - 'deps': ['d3'], + 'deps': ['d3', 'underscore'], }, 'dagreD3': { 'deps': ['d3'], diff --git a/dashboardv2/public/js/views/graph/ProfileBarChart.js b/dashboardv2/public/js/views/graph/ProfileBarChart.js index 78d2c904b..96c04888f 100644 --- a/dashboardv2/public/js/views/graph/ProfileBarChart.js +++ b/dashboardv2/public/js/views/graph/ProfileBarChart.js @@ -16,8 +16,21 @@ * limitations under the License. */ -define(["require", "d3", "d3-tip"], function(require, d3, d3Tip) { +define(["require", "d3", "d3-tip", "underscore"], function(require, d3, d3Tip, _) { "use strict"; + + var buildTooltipHtml = function(d, type) { + return ( + "<table><thead><tr><td colspan=\"3\"><strong class=\"x-value\">" + + _.escape(d.value) + + "</strong></td></tr></thead><tbody><tr><td class=\"key\">" + + _.escape(type) + + "</td><td class=\"value\">" + + _.escape(d.count) + + "</td></tr></tbody></table>" + ); + }; + var ProfileBarChart = { render: function(options) { var el = options.el, @@ -103,7 +116,7 @@ define(["require", "d3", "d3-tip"], function(require, d3, d3Tip) { .attr("class", "d3-tip") .offset([10, 0]) .html(function(d) { - return '<table><thead><tr><td colspan="3"><strong class="x-value">' + d.value + '</strong></td></tr></thead><tbody><tr><td class="key">' + type + '</td><td class="value">' + d.count + '</td></tr></tbody></table>'; + return buildTooltipHtml(d, type); }); // append the rectangles for the bar chart
