Skip to content

Commit 95efd62

Browse files
ATLAS-5370: Atlas UI : Encode text in D3 graph tooltips Lineage and Profile charts (#722)
* Atlas UI : Encode text in D3 graph tooltips Lineage and Profile charts * ATLAS-5370: Atlas UI : Encode text in D3 graph tooltips Lineage and Profile charts ( cherry-picked from the commit 1abb136)
1 parent 3628021 commit 95efd62

9 files changed

Lines changed: 299 additions & 16 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
jest.mock("../LineageUtils", () => ({ default: {} }));
19+
jest.mock("../DataUtils", () => ({ default: {} }));
20+
21+
import { escapeHtml } from "../index";
22+
23+
/**
24+
* Mirrors underscore _.escape used in classic UI:
25+
* - atlas-lineage (dashboardv2/external_lib)
26+
* - ProfileBarChart.js
27+
*/
28+
const underscoreEscape = (value: unknown): string => {
29+
if (value == null) {
30+
return "";
31+
}
32+
33+
const escapeMap: Record<string, string> = {
34+
"&": "&amp;",
35+
"<": "&lt;",
36+
">": "&gt;",
37+
'"': "&quot;",
38+
"'": "&#x27;",
39+
"`": "&#x60;"
40+
};
41+
42+
return String(value).replace(/[&<>"'`]/g, (match) => escapeMap[match]);
43+
};
44+
45+
const expectSafeTooltipText = (encoded: string) => {
46+
expect(encoded).not.toMatch(/<[^&!]/);
47+
expect(encoded).not.toContain("<script");
48+
expect(encoded).not.toContain("<img");
49+
};
50+
51+
describe("lineage tooltip escapeHtml (React)", () => {
52+
describe("HTML metacharacter encoding", () => {
53+
it("escapes angle brackets in malicious tooltip labels", () => {
54+
const payload = '<img src=x onerror="window.__xss=1">';
55+
const escaped = escapeHtml(payload);
56+
57+
expect(escaped).not.toContain("<img");
58+
expect(escaped).toContain("&lt;img");
59+
expect(escaped).toContain("&quot;");
60+
expectSafeTooltipText(escaped);
61+
});
62+
63+
it("escapes script tags", () => {
64+
const escaped = escapeHtml('<script>alert("x")</script>');
65+
expect(escaped).toBe("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
66+
expectSafeTooltipText(escaped);
67+
});
68+
69+
it("escapes ampersand", () => {
70+
expect(escapeHtml("a & b")).toBe("a &amp; b");
71+
});
72+
73+
it("double-encodes already-encoded ampersand entities safely", () => {
74+
expect(escapeHtml("a &amp; b")).toBe("a &amp;amp; b");
75+
expectSafeTooltipText(escapeHtml("a &amp; b"));
76+
});
77+
78+
it("escapes single quotes", () => {
79+
expect(escapeHtml("O'Brien")).toBe("O&#39;Brien");
80+
});
81+
82+
it("escapes double quotes", () => {
83+
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
84+
});
85+
86+
it("escapes all special characters in one payload", () => {
87+
const payload = `<a href='x'>&"test"`;
88+
const escaped = escapeHtml(payload);
89+
expect(escaped).toBe("&lt;a href=&#39;x&#39;&gt;&amp;&quot;test&quot;");
90+
expectSafeTooltipText(escaped);
91+
});
92+
});
93+
94+
describe("nullish and empty inputs", () => {
95+
it("handles null and undefined values", () => {
96+
expect(escapeHtml(null)).toBe("");
97+
expect(escapeHtml(undefined)).toBe("");
98+
});
99+
100+
it("handles empty string", () => {
101+
expect(escapeHtml("")).toBe("");
102+
});
103+
104+
it("handles whitespace-only label used when displayText is missing", () => {
105+
expect(escapeHtml(" ")).toBe(" ");
106+
});
107+
});
108+
109+
describe("numeric and coerced values", () => {
110+
it("coerces numeric values to string", () => {
111+
expect(escapeHtml(123)).toBe("123");
112+
expect(escapeHtml(0)).toBe("0");
113+
});
114+
115+
it("coerces boolean values to string", () => {
116+
expect(escapeHtml(true)).toBe("true");
117+
expect(escapeHtml(false)).toBe("false");
118+
});
119+
});
120+
121+
describe("plain text preservation", () => {
122+
it("preserves safe entity display names", () => {
123+
expect(escapeHtml("sales_db.customers")).toBe("sales_db.customers");
124+
});
125+
126+
it("preserves qualified names with dots and underscores", () => {
127+
expect(escapeHtml("hive.db.table_v1")).toBe("hive.db.table_v1");
128+
});
129+
130+
it("preserves unicode characters in entity names", () => {
131+
expect(escapeHtml("データ_テーブル")).toBe("データ_テーブル");
132+
});
133+
134+
it("preserves newlines in multiline query text", () => {
135+
const queryText = "SELECT *\nFROM t\nWHERE id = 1";
136+
expect(escapeHtml(queryText)).toBe(queryText);
137+
expectSafeTooltipText(escapeHtml(queryText));
138+
});
139+
});
140+
141+
describe("lineage tooltip field scenarios", () => {
142+
it("encodes toolTipLabel from entity displayText", () => {
143+
const label = 'db.table<script>alert(1)</script>';
144+
const escaped = escapeHtml(label);
145+
expect(escaped).toBe("db.table&lt;script&gt;alert(1)&lt;/script&gt;");
146+
expectSafeTooltipText(escaped);
147+
});
148+
149+
it("encodes typeName values", () => {
150+
const escaped = escapeHtml("hive_table<script>");
151+
expect(escaped).toBe("hive_table&lt;script&gt;");
152+
expectSafeTooltipText(escaped);
153+
});
154+
155+
it("encodes queryText with SQL quotes and comparison operators", () => {
156+
const queryText =
157+
"SELECT * FROM t WHERE x < 1 AND name = \"foo\" AND y > 0";
158+
const escaped = escapeHtml(queryText);
159+
160+
expect(escaped).toContain("&lt;");
161+
expect(escaped).toContain("&gt;");
162+
expect(escaped).toContain("&quot;");
163+
expectSafeTooltipText(escaped);
164+
});
165+
166+
it("encodes static toolTipTitle values", () => {
167+
expect(escapeHtml("Type")).toBe("Type");
168+
expect(escapeHtml('Type<script>')).toBe("Type&lt;script&gt;");
169+
});
170+
});
171+
172+
describe("intentional differences from classic _.escape", () => {
173+
it("does not encode backtick in text content (safe inside span tags)", () => {
174+
expect(escapeHtml("db`table")).toBe("db`table");
175+
expectSafeTooltipText(escapeHtml("db`table"));
176+
});
177+
});
178+
});
179+
180+
describe("classic UI tooltip encoding (underscore _.escape parity)", () => {
181+
const tooltipPayloads = [
182+
'<img src=x onerror="alert(1)">',
183+
"O'Brien",
184+
"a & b",
185+
"sales_db.customers",
186+
123,
187+
"",
188+
null,
189+
undefined
190+
];
191+
192+
it.each(tooltipPayloads)(
193+
"underscoreEscape produces safe output for %p",
194+
(payload) => {
195+
const escaped = underscoreEscape(payload);
196+
197+
if (payload == null) {
198+
expect(escaped).toBe("");
199+
return;
200+
}
201+
202+
expectSafeTooltipText(escaped);
203+
204+
const raw = String(payload);
205+
if (/[&<>"'`]/.test(raw)) {
206+
expect(escaped).not.toBe(raw);
207+
} else {
208+
expect(escaped).toBe(raw);
209+
}
210+
}
211+
);
212+
213+
it("handles null and undefined like production _.escape", () => {
214+
expect(underscoreEscape(null)).toBe("");
215+
expect(underscoreEscape(undefined)).toBe("");
216+
});
217+
218+
it("preserves numeric count values for profile bar chart tooltips", () => {
219+
expect(underscoreEscape(42)).toBe("42");
220+
expect(underscoreEscape(0)).toBe("0");
221+
});
222+
223+
it("encodes lineage queryText with quotes and angle brackets", () => {
224+
const queryText = 'SELECT * FROM t WHERE x < 1 AND name = "foo"';
225+
const escaped = underscoreEscape(queryText);
226+
227+
expect(escaped).toContain("&lt;");
228+
expect(escaped).toContain("&quot;");
229+
expectSafeTooltipText(escaped);
230+
});
231+
232+
it("encodes lineage typeName values", () => {
233+
const escaped = underscoreEscape("hive_table<script>");
234+
expect(escaped).toBe("hive_table&lt;script&gt;");
235+
expectSafeTooltipText(escaped);
236+
});
237+
238+
it("encodes backtick characters used in entity names", () => {
239+
expect(underscoreEscape("db`table")).toBe("db&#x60;table");
240+
});
241+
242+
it("react escapeHtml and classic _.escape both produce safe tooltip output", () => {
243+
const payloads = [
244+
'<img src=x onerror="alert(1)">',
245+
'<script>alert(1)</script>',
246+
"O'Brien",
247+
"a & b",
248+
"hive_process",
249+
'SELECT * FROM t WHERE x < 1 AND name = "foo"',
250+
"hive_table<script>"
251+
];
252+
253+
payloads.forEach((payload) => {
254+
expectSafeTooltipText(escapeHtml(payload));
255+
expectSafeTooltipText(underscoreEscape(payload));
256+
});
257+
});
258+
});

dashboard/src/views/Lineage/atlas-lineage/src/Utils/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,18 @@ const isEmpty = (value) =>
2525
(typeof value === "object" && Object.keys(value).length === 0) ||
2626
(typeof value === "string" && value.trim().length === 0);
2727

28+
const escapeHtml = (value) =>
29+
String(value ?? "")
30+
.replace(/&/g, "&amp;")
31+
.replace(/</g, "&lt;")
32+
.replace(/>/g, "&gt;")
33+
.replace(/"/g, "&quot;")
34+
.replace(/'/g, "&#39;");
35+
2836
String.prototype.trunc =
2937
String.prototype.trunc ||
3038
function(n) {
3139
return this.length > n ? this.substr(0, n - 1) + "..." : this;
3240
};
3341

34-
export { LineageUtils, DataUtils, isEmpty };
42+
export { LineageUtils, DataUtils, isEmpty, escapeHtml };

dashboard/src/views/Lineage/atlas-lineage/src/index.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import dagreD3 from "dagre-d3";
2020
import { select, selection, event } from "d3-selection";
2121
import { curveBasis } from "d3-shape";
22-
import { LineageUtils, DataUtils, isEmpty } from "./Utils";
22+
import { LineageUtils, DataUtils, isEmpty, escapeHtml } from "./Utils";
2323
import d3Tip from "d3-tip";
2424

2525
import "./styles/style.scss";
@@ -543,7 +543,9 @@ export default class LineageHelper {
543543
var htmlStr = "";
544544
if (toolTipTitle) {
545545
htmlStr =
546-
"<h5 style='text-align: center;'>" + toolTipTitle + "</h5>";
546+
"<h5 style='text-align: center;'>" +
547+
escapeHtml(toolTipTitle) +
548+
"</h5>";
547549
} else if (value.id !== this.guid) {
548550
htmlStr =
549551
"<h5 style='text-align: center;'>" +
@@ -553,18 +555,18 @@ export default class LineageHelper {
553555

554556
htmlStr +=
555557
"<h5 class='text-center'><span style='color:#359f89'>" +
556-
value.toolTipLabel +
558+
escapeHtml(value.toolTipLabel) +
557559
"</span></h5> ";
558560
if (value.typeName) {
559561
htmlStr +=
560562
"<h5 class='text-center'><span>(" +
561-
value.typeName +
563+
escapeHtml(value.typeName) +
562564
")</span></h5> ";
563565
}
564566
if (value.queryText) {
565567
htmlStr +=
566568
"<h5>Query: <span style='color:#359f89'>" +
567-
value.queryText +
569+
escapeHtml(value.queryText) +
568570
"</span></h5> ";
569571
}
570572
return "<div class='tip-inner-scroll'>" + htmlStr + "</div>";

dashboardv2/public/js/external_lib/atlas-lineage/dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboardv2/public/js/external_lib/atlas-lineage/src/index.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { select, selection, event } from "d3-selection";
2121
import { curveBasis } from "d3-shape";
2222
import { LineageUtils, DataUtils } from "./Utils";
2323
import d3Tip from "d3-tip";
24+
import _ from "underscore";
2425

2526
import "./styles/style.scss";
2627

@@ -507,17 +508,17 @@ export default class LineageHelper {
507508
var value = g.node(d);
508509
var htmlStr = "";
509510
if (toolTipTitle) {
510-
htmlStr = "<h5 style='text-align: center;'>" + toolTipTitle + "</h5>";
511+
htmlStr = "<h5 style='text-align: center;'>" + _.escape(toolTipTitle) + "</h5>";
511512
} else if (value.id !== this.guid) {
512513
htmlStr = "<h5 style='text-align: center;'>" + (value.isLineage ? "Lineage" : "Impact") + "</h5>";
513514
}
514515

515-
htmlStr += "<h5 class='text-center'><span style='color:#359f89'>" + value.toolTipLabel + "</span></h5> ";
516+
htmlStr += "<h5 class='text-center'><span style='color:#359f89'>" + _.escape(value.toolTipLabel) + "</span></h5> ";
516517
if (value.typeName) {
517-
htmlStr += "<h5 class='text-center'><span>(" + value.typeName + ")</span></h5> ";
518+
htmlStr += "<h5 class='text-center'><span>(" + _.escape(value.typeName) + ")</span></h5> ";
518519
}
519520
if (value.queryText) {
520-
htmlStr += "<h5>Query: <span style='color:#359f89'>" + value.queryText + "</span></h5> ";
521+
htmlStr += "<h5>Query: <span style='color:#359f89'>" + _.escape(value.queryText) + "</span></h5> ";
521522
}
522523
return "<div class='tip-inner-scroll'>" + htmlStr + "</div>";
523524
}

dashboardv2/public/js/external_lib/atlas-lineage/webpack.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ const config = {
4040
"dagre-d3": "dagreD3",
4141
platform: "platform",
4242
dagre: "dagre",
43-
graphlib: "graphlib"
43+
graphlib: "graphlib",
44+
underscore: "underscore"
4445
},
4546
module: {
4647
rules: [

dashboardv2/public/js/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ require.config({
9696
'exports': ['d3-tip']
9797
},
9898
'LineageHelper': {
99-
'deps': ['d3'],
99+
'deps': ['d3', 'underscore'],
100100
},
101101
'dagreD3': {
102102
'deps': ['d3'],

dashboardv2/public/js/migration.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ require.config({
9595
'exports': ['d3-tip']
9696
},
9797
'LineageHelper': {
98-
'deps': ['d3'],
98+
'deps': ['d3', 'underscore'],
9999
},
100100
'dagreD3': {
101101
'deps': ['d3'],

0 commit comments

Comments
 (0)