diff --git a/dashboard/jest.config.js b/dashboard/jest.config.js index 22648e6e3da..e118a585f45 100644 --- a/dashboard/jest.config.js +++ b/dashboard/jest.config.js @@ -36,6 +36,7 @@ const config = { // Module name mapping for path aliases moduleNameMapper: { + '^sanitize-html$': '/src/__mocks__/sanitize-html.ts', '^@/(.*)$': '/src/$1', '^@components/(.*)$': '/src/components/$1', '^@api/(.*)\.js$': '/src/api/$1.ts', diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 9937d1337af..895962cf6a3 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -38,7 +38,7 @@ "react-router-dom": "6.30.4", "react-toastify": "10.0.5", "recharts": "2.15.1", - "sanitize-html": "2.17.4" + "sanitize-html": "2.17.6" }, "devDependencies": { "@babel/preset-env": "7.28.5", @@ -6037,6 +6037,7 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", + "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -6049,6 +6050,7 @@ }, "node_modules/domelementtype": { "version": "2.3.0", + "dev": true, "funding": [ { "type": "github", @@ -6070,6 +6072,7 @@ }, "node_modules/domhandler": { "version": "5.0.3", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -6083,6 +6086,7 @@ }, "node_modules/domutils": { "version": "3.2.2", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -6128,6 +6132,7 @@ }, "node_modules/entities": { "version": "4.5.0", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -7036,6 +7041,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -7055,6 +7061,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -10286,18 +10293,122 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.4", - "resolved": "https://registry.npmmirror.com/sanitize-html/-/sanitize-html-2.17.4.tgz", - "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", + "version": "2.17.6", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz", + "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", - "htmlparser2": "^10.1.0", + "htmlparser2": "^12.0.0", "is-plain-object": "^5.0.0", "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/sass": { diff --git a/dashboard/package.json b/dashboard/package.json index 627a017bf19..d1ba2c1dd77 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -56,7 +56,7 @@ "react-router-dom": "6.30.4", "react-toastify": "10.0.5", "recharts": "2.15.1", - "sanitize-html": "2.17.4" + "sanitize-html": "2.17.6" }, "devDependencies": { "@babel/preset-env": "7.28.5", diff --git a/dashboard/src/__mocks__/sanitize-html.ts b/dashboard/src/__mocks__/sanitize-html.ts new file mode 100644 index 00000000000..542bc57eb6b --- /dev/null +++ b/dashboard/src/__mocks__/sanitize-html.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ + +type SanitizeFn = (html: string, options?: Record) => string; +let actualSanitizeModule: SanitizeFn | { default: SanitizeFn } | null | undefined; + +const getSanitizeFn = (): SanitizeFn | null => { + if (actualSanitizeModule === undefined) { + try { + actualSanitizeModule = jest.requireActual('sanitize-html/index.js') as SanitizeFn | { default: SanitizeFn }; + } catch (e) { + actualSanitizeModule = null; + } + } + + if (typeof actualSanitizeModule === 'function') { + return actualSanitizeModule; + } + if (actualSanitizeModule && typeof actualSanitizeModule === 'object' && 'default' in actualSanitizeModule && typeof actualSanitizeModule.default === 'function') { + return actualSanitizeModule.default; + } + + return null; +}; + +const sanitizeHtml = (html: string, options?: Record) => { + const sanitizeFn = getSanitizeFn(); + + if (options && typeof sanitizeFn === 'function') { + return sanitizeFn(html, options); + } + + const htmlStr = typeof html === 'string' ? html : String(html); + return htmlStr.replace(/)<[^<]*)*<\/script>/gi, ''); +}; +export default sanitizeHtml; diff --git a/dashboard/src/setupTests.simple.ts b/dashboard/src/setupTests.simple.ts index d1bca5c8461..29df8ae2e1a 100644 --- a/dashboard/src/setupTests.simple.ts +++ b/dashboard/src/setupTests.simple.ts @@ -18,30 +18,37 @@ /** Simplified test setup file for Node 12 compatibility */ import '@testing-library/jest-dom'; +import { TextEncoder, TextDecoder } from 'util'; export {}; +// Polyfill TextEncoder and TextDecoder for React Router DOM in Jest +if (typeof global.TextEncoder === 'undefined') { + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder; +} + // Basic mocks that don't rely on newer JS features -(global as any).ResizeObserver = function() { - return { - observe: function() {}, - unobserve: function() {}, - disconnect: function() {} - }; -}; +Object.assign(global, { + ResizeObserver: class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } +}); -(global as any).IntersectionObserver = function() { - return { - observe: function() {}, - unobserve: function() {}, - disconnect: function() {}, - takeRecords: function() { return []; }, - root: null, - rootMargin: '', - thresholds: [] - }; -}; +Object.assign(global, { + IntersectionObserver: class IntersectionObserver { + root = null; + rootMargin = ''; + thresholds = []; + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { return []; } + } +}); // Mock window.matchMedia (jsdom / browser tests only) if (typeof window !== 'undefined') { diff --git a/dashboard/src/utils/__tests__/Utils.test.ts b/dashboard/src/utils/__tests__/Utils.test.ts index 6101e7f2726..82cb6b2ffbe 100644 --- a/dashboard/src/utils/__tests__/Utils.test.ts +++ b/dashboard/src/utils/__tests__/Utils.test.ts @@ -22,6 +22,7 @@ * Coverage Target: 100% for Statements, Branches, Functions, and Lines */ + import { customSortBy, customSortByObjectKeys, @@ -878,11 +879,80 @@ describe('Utils', () => { }); describe('sanitizeHtmlContent', () => { - it('should sanitize HTML content', () => { - const html = '

Safe content

'; - const result = sanitizeHtmlContent(html); + it('should allow configured positive HTML tags and attributes', () => { + const safeHtml = ` +

Heading

+

This is a bold and italic text.

+
  • List item
+ Valid link + `; + const result = sanitizeHtmlContent(safeHtml); + expect(result).toContain('

Heading

'); + expect(result).toContain('bold'); + expect(result).toContain('italic'); + expect(result).toContain('
  • List item
'); + expect(result).toContain('Valid link'); + }); + + it('should strip malicious and unconfigured tags (negative XSS cases)', () => { + const xssHtml = ` + +

Safe content

+ Malicious link + +
Click me
+ `; + const result = sanitizeHtmlContent(xssHtml); + + //