From e9ef684ccaf23b48897fa46dfec3334062add886 Mon Sep 17 00:00:00 2001 From: hamir-suspect Date: Mon, 29 Sep 2025 17:12:31 +0200 Subject: [PATCH 1/3] fix(front): Enable mermaid rendering on reports tab --- front/assets/js/report/index.tsx | 39 +- front/assets/package-lock.json | 174 +++---- front/assets/package.json | 3 +- front/test/browser/report_security_test.exs | 516 ++++++++++++++++++++ 4 files changed, 642 insertions(+), 90 deletions(-) create mode 100644 front/test/browser/report_security_test.exs diff --git a/front/assets/js/report/index.tsx b/front/assets/js/report/index.tsx index ed07c5702..95ff10cb4 100644 --- a/front/assets/js/report/index.tsx +++ b/front/assets/js/report/index.tsx @@ -9,7 +9,7 @@ import DOMPurify from 'dompurify'; import * as toolbox from "js/toolbox"; import { useEffect, useState } from "preact/hooks"; -Mermaid.initialize({ startOnLoad: false, theme: `default`, securityLevel: `strict` }); +Mermaid.initialize({ startOnLoad: false, theme: `default`, securityLevel: `sandbox` }); const md = MarkdownIt({ html: true, linkify: false, @@ -83,6 +83,21 @@ const MarkdownBody = (props: { markdown: string, }) => { }, [props.markdown]); const renderedHtml = md.render(props.markdown); + // Note: Sanitization is applied here before mermaid rendering. Some mermaid-specific tags + // (like those generated by markdown-it-textual-uml) are intentionally not included in ALLOWED_TAGS + // because they're processed by Mermaid.run() after sanitization. + // Additionally, Mermaid provides two more security layers: + // 1. It uses DOMPurify internally for sanitizing diagram content + // 2. With securityLevel: 'sandbox' configured above, it renders each diagram in a sandboxed iframe + // First, configure DOMPurify hooks + DOMPurify.addHook('afterSanitizeAttributes', function(node) { + // Force all links to open in new tab with security attributes + if ('tagName' in node && node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer nofollow'); + } + }); + const sanitizedHtml = DOMPurify.sanitize(renderedHtml, { ALLOWED_TAGS: [ // Basic @@ -98,17 +113,29 @@ const MarkdownBody = (props: { markdown: string, }) => { `mark`, `ins`, `small`, - `abbr` + `abbr`, + // Links (safe with DOMPurify's URL sanitization) + `a` ], ALLOWED_ATTR: [ `title`, - `open` + `open`, + `class`, + `href`, // DOMPurify by default blocks dangerous protocols (javascript:, data:, vbscript:) and only allows safe ones (http:, https:, mailto:, etc.) + `target`, + `rel` ], - FORBID_TAGS: [`a`, `img`, `script`, `object`, `embed`, `iframe`, `link`], - FORBID_ATTR: [`href`, `src`, `class`, `id`, `style`, `target`], - ALLOW_DATA_ATTR: false + // Critical: Keep blocking dangerous tags that were part of the original vulnerability + FORBID_TAGS: [`img`, `script`, `object`, `embed`, `iframe`, `link`, `form`, `input`, `style`, `meta`, `base`], + FORBID_ATTR: [`src`, `id`, `style`, `onclick`, `onload`, `onerror`, `action`, `method`], + ALLOW_DATA_ATTR: false, + // Force secure link attributes + ADD_ATTR: [`target`, `rel`] }); + // Clean up the hook after sanitization to avoid memory leaks + DOMPurify.removeHook('afterSanitizeAttributes'); + return (
=14" @@ -9650,13 +9661,13 @@ } }, "node_modules/mermaid": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz", - "integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.0.tgz", + "integrity": "sha512-ZudVx73BwrMJfCFmSSJT84y6u5brEoV8DOItdHomNLz32uBjNrelm7mg95X7g+C6UoQH/W6mBLGDEDv73JdxBg==", "dependencies": { - "@braintree/sanitize-url": "^7.0.4", - "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.4.0", + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", @@ -9664,12 +9675,12 @@ "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", - "dompurify": "^3.2.4", - "katex": "^0.16.9", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^15.0.7", + "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -9677,14 +9688,14 @@ } }, "node_modules/mermaid/node_modules/marked": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", - "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/mermaid/node_modules/uuid": { @@ -10206,20 +10217,20 @@ } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mlly/node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "bin": { "acorn": "bin/acorn" }, @@ -11181,12 +11192,9 @@ } }, "node_modules/package-manager-detector": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", - "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "dependencies": { - "quansync": "^0.2.7" - } + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==" }, "node_modules/pako": { "version": "2.1.0", @@ -11340,12 +11348,12 @@ } }, "node_modules/pkg-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", - "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "dependencies": { - "confbox": "^0.2.1", - "exsolve": "^1.0.1", + "confbox": "^0.2.2", + "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, @@ -12118,11 +12126,11 @@ } }, "node_modules/posthog-js": { - "version": "1.261.6", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.6.tgz", - "integrity": "sha512-tson+4i+T2YkGYlj/oGjFwKRpBFqhM7Xr9ZmXGEtNFkZc6ZQHYCzObeeHT6BbKc5d/dAfMCPtvPCKssARaK6eQ==", + "version": "1.268.8", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz", + "integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==", "dependencies": { - "@posthog/core": "1.0.2", + "@posthog/core": "1.2.2", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", @@ -12315,9 +12323,9 @@ } }, "node_modules/quansync": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", - "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { "type": "individual", @@ -13252,9 +13260,9 @@ "dev": true }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==" }, "node_modules/tinyglobby": { "version": "0.2.14", diff --git a/front/assets/package.json b/front/assets/package.json index 10a42a737..ef99bf15f 100644 --- a/front/assets/package.json +++ b/front/assets/package.json @@ -43,7 +43,7 @@ "markdown-it": "^14.1.0", "markdown-it-textual-uml": "^0.17.1", "marked": "^9.1.2", - "mermaid": "^11.6.0", + "mermaid": "^11.12.0", "moment": "^2.29.4", "moment-duration-format": "^2.3.2", "monaco-yaml": "^5.2.3", @@ -68,6 +68,7 @@ "@types/chai": "^4.3.1", "@types/d3": "^7.4.0", "@types/d3-time-format": "^4.0.0", + "@types/dompurify": "^3.0.5", "@types/jquery": "^3.5.14", "@types/jsdom": "^21.1.5", "@types/lodash": "^4.14.182", diff --git a/front/test/browser/report_security_test.exs b/front/test/browser/report_security_test.exs new file mode 100644 index 000000000..a449f3ee7 --- /dev/null +++ b/front/test/browser/report_security_test.exs @@ -0,0 +1,516 @@ +defmodule Front.Browser.ReportSecurityTest do + use FrontWeb.WallabyCase + + alias Support.Stubs + + import Wallaby.Query, only: [css: 1, css: 2, link: 1, xpath: 1] + + describe "Markdown Report Security" do + setup do + user = Stubs.User.create_default() + org = Stubs.Organization.create_default() + Support.Stubs.Feature.enable_feature(org.id, :job_reports) + Support.Stubs.Feature.enable_feature(org.id, :workflow_reports) + Support.Stubs.PermissionPatrol.allow_everything(org.id, user.id) + + project = Stubs.Project.create(org, user) + branch = Stubs.Branch.create(project) + hook = Stubs.Hook.create(branch) + workflow = Stubs.Workflow.create(hook, user) + + pipeline = + Stubs.Pipeline.create_initial(workflow, name: "Build & Test", organization_id: org.id) + + block = Stubs.Block.create(pipeline) + job = Stubs.Job.create(block) + + {:ok, + %{ + user: user, + org: org, + project: project, + workflow: workflow, + pipeline: pipeline, + job: job + }} + end + + test "XSS Prevention - javascript: protocol in links should be sanitized", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + [Click me](javascript:alert('XSS')) + Direct JS link + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> assert_no_js_protocol_links() + |> refute_has(css("a[href^='javascript:']")) + end + + test "XSS Prevention - data: URLs with scripts should be blocked", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + Data URL XSS + [Data URL](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=) + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("a[href^='data:']")) + end + + test "XSS Prevention - onclick and event handlers should be removed", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + Link with onclick + Link with onmouseover +
Div with onclick
+ + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> assert_no_event_handlers() + end + + test "CSRF Prevention - form elements should be completely blocked", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report +
+ + +
+ """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("form")) + |> refute_has(css("input")) + end + + test "CSS Injection Prevention - style tags should be blocked", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("style")) + end + + test "Script Injection Prevention - script tags should be blocked", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + + + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("script")) + end + + test "Dangerous Elements - iframe, embed, object should be blocked", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + + + + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("iframe")) + |> refute_has(css("embed")) + |> refute_has(css("object")) + end + + test "VBScript Protocol - should be sanitized", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + VBScript XSS + """ + + Stubs.Artifact.create_job_report(job.id, malicious_content) + + session + |> visit("/jobs/#{job.id}/reports") + |> refute_has(css("a[href^='vbscript:']")) + end + + test "Safe Links - HTTPS links should be allowed and have security attributes", %{ + session: session, + job: job + } do + safe_content = """ + # Test Report + [GitHub](https://github.com) + [Example](https://example.com) + Safe Link + """ + + Stubs.Artifact.create_job_report(job.id, safe_content) + + page = + session + |> visit("/jobs/#{job.id}/reports") + + # Check that safe links are present + assert_has(page, css("a[href='https://github.com']")) + assert_has(page, css("a[href='https://example.com']")) + assert_has(page, css("a[href='https://safe-site.com']")) + + # Check security attributes + page + |> assert_link_security_attributes() + end + + test "Safe Links - mailto links should be allowed", %{ + session: session, + job: job + } do + content = """ + # Test Report + [Email Us](mailto:test@example.com) + """ + + Stubs.Artifact.create_job_report(job.id, content) + + session + |> visit("/jobs/#{job.id}/reports") + |> assert_has(css("a[href^='mailto:']")) + end + + test "Mermaid Diagrams - should not allow HTML injection", %{ + session: session, + job: job + } do + content = """ + # Test Report + ```mermaid + graph TD + A[Start] --> B{Is it?} + B -->|Yes| C[OK] + B -->|No| D[End] + ``` + """ + + Stubs.Artifact.create_job_report(job.id, content) + + page = + session + |> visit("/jobs/#{job.id}/reports") + + # Mermaid diagrams should be rendered in sandboxed iframes + assert_has(page, css(".mermaid")) + + # Ensure no script injection in mermaid content + refute_mermaid_has_scripts(page) + end + + test "Mermaid click directives - should allow clicks but sanitize javascript: URLs", %{ + session: session, + job: job + } do + content = """ + # Test Report + ```mermaid + graph TD + A[Clickable Node] --> B[Another Node] + click A "javascript:alert('XSS')" + click B "https://safe-site.com" + ``` + """ + + Stubs.Artifact.create_job_report(job.id, content) + + page = + session + |> visit("/jobs/#{job.id}/reports") + + # Check that mermaid rendered + assert_has(page, css(".mermaid")) + + # Verify that javascript: URLs are sanitized in click handlers + page + |> execute_script(""" + const mermaidElements = document.querySelectorAll('.mermaid a, .mermaid [onclick]'); + for (let el of mermaidElements) { + if (el.href && el.href.startsWith('javascript:')) return true; + if (el.onclick && el.onclick.toString().includes('alert')) return true; + } + return false; + """) + |> then(fn result -> refute result, "JavaScript URLs should be sanitized in Mermaid click handlers" end) + + # But safe URLs should still work + page + |> execute_script(""" + const mermaidElements = document.querySelectorAll('.mermaid a'); + return Array.from(mermaidElements).some(el => + el.href && el.href.includes('safe-site.com') + ); + """) + |> then(fn result -> assert result, "Safe URLs in Mermaid click directives should be preserved" end) + end + + test "Mermaid Diagrams - malformed syntax with script injection attempts", %{ + session: session, + job: job + } do + malicious_content = """ + # Test Report + ```mermaid + + +
+ +
+
+      ```
+      """
+
+      Stubs.Artifact.create_job_report(job.id, malicious_content)
+
+      session
+      |> visit("/jobs/#{job.id}/reports")
+      |> refute_has(css("script"))
+      |> refute_has(css("form"))
+    end
+
+    test "Content Preservation - safe markdown should be rendered correctly", %{
+      session: session,
+      job: job
+    } do
+      safe_content = """
+      # Main Title
+      ## Subtitle
+      **Bold text** and *italic text*
+      - List item 1
+      - List item 2
+
+      | Column 1 | Column 2 |
+      |----------|----------|
+      | Data 1   | Data 2   |
+
+      ```javascript
+      const safe = "code block";
+      ```
+
+      
+ Click to expand + This is hidden content +
+ """ + + Stubs.Artifact.create_job_report(job.id, safe_content) + + page = + session + |> visit("/jobs/#{job.id}/reports") + + # Check all safe elements are preserved + assert_has(page, css("h1")) + assert_has(page, css("h2")) + assert_has(page, css("strong")) + assert_has(page, css("em")) + assert_has(page, css("ul")) + assert_has(page, css("table")) + assert_has(page, css("code")) + assert_has(page, css("details")) + assert_has(page, css("summary")) + + # Check content is preserved + assert_text(page, "Main Title") + assert_text(page, "Bold text") + assert_text(page, "List item 1") + assert_text(page, "Data 1") + end + + test "Mixed Content - safe content preserved, unsafe removed", %{ + session: session, + job: job + } do + mixed_content = """ + # Safe Title + + This is safe text + Unsafe link + [Safe link](https://example.com) +
Unsafe form
+ **Safe bold text** + """ + + Stubs.Artifact.create_job_report(job.id, mixed_content) + + page = + session + |> visit("/jobs/#{job.id}/reports") + + # Safe content should be present + assert_text(page, "Safe Title") + assert_text(page, "This is safe text") + assert_text(page, "Safe bold text") + assert_has(page, css("h1")) + assert_has(page, css("strong")) + assert_has(page, css("a[href='https://example.com']")) + + # Unsafe content should be removed + refute_has(page, css("script")) + refute_has(page, css("form")) + refute_has(page, css("a[href*='javascript']")) + end + + test "Workflow Reports - same security rules apply", %{ + session: session, + workflow: workflow + } do + malicious_content = """ + # Workflow Report + +
+ +
+ Click + """ + + Stubs.Artifact.create_workflow_report(workflow.id, malicious_content) + + session + |> visit("/workflows/#{workflow.id}/reports") + |> refute_has(css("script")) + |> refute_has(css("form")) + |> refute_has(css("input")) + |> refute_has(css("a[href^='javascript:']")) + end + + test "Project Reports - same security rules apply", %{ + session: session, + project: project + } do + malicious_content = """ + # Project Report + + + Data URL + """ + + Stubs.Artifact.create_project_report(project.id, malicious_content) + + session + |> visit("/projects/#{project.name}/reports") + |> refute_has(css("style")) + |> refute_has(css("iframe")) + |> refute_has(css("a[href^='data:']")) + end + end + + # Helper functions + + defp assert_no_js_protocol_links(session) do + session + |> execute_script(""" + return Array.from(document.querySelectorAll('a')).some(link => + link.href && link.href.startsWith('javascript:') + ); + """) + |> then(fn result -> refute result end) + + session + end + + defp assert_no_event_handlers(session) do + session + |> execute_script(""" + const elements = document.querySelectorAll('*'); + for (let el of elements) { + if (el.onclick || el.onmouseover || el.onerror || el.onload) { + return true; + } + } + return false; + """) + |> then(fn result -> refute result end) + + session + end + + defp assert_link_security_attributes(session) do + session + |> execute_script(""" + const links = document.querySelectorAll('a[href^="https://"]'); + return Array.from(links).every(link => + link.target === '_blank' && + link.rel && link.rel.includes('noopener') && link.rel.includes('noreferrer') + ); + """) + |> then(fn result -> assert result end) + + session + end + + defp refute_mermaid_has_scripts(session) do + session + |> execute_script(""" + const mermaidElements = document.querySelectorAll('.mermaid'); + for (let el of mermaidElements) { + if (el.innerHTML.includes(' then(fn result -> refute result end) + + session + end +end \ No newline at end of file From c75c12a9e51e2760d5c5c09e90bdb39f1c62e3e8 Mon Sep 17 00:00:00 2001 From: hamir-suspect Date: Tue, 30 Sep 2025 11:32:26 +0200 Subject: [PATCH 2/3] fix(front): Remove test that can't work --- front/priv/storage/reports/job_report.md | 55 ++- front/priv/storage/reports/wf_report.md | 180 ++++++- front/test/browser/report_security_test.exs | 516 -------------------- 3 files changed, 218 insertions(+), 533 deletions(-) delete mode 100644 front/test/browser/report_security_test.exs diff --git a/front/priv/storage/reports/job_report.md b/front/priv/storage/reports/job_report.md index cb7950d64..d23b8f151 100644 --- a/front/priv/storage/reports/job_report.md +++ b/front/priv/storage/reports/job_report.md @@ -1,21 +1,46 @@ -# Job Report +## 🎯 System Metrics Summary -This is a test job report. +**Total datapoints:** `40` +**🕒 Time Range:** `Tue Sep 23 08:57:29 UTC 2025` → `Tue Sep 23 08:58:10 UTC 2025` + +- **🔥 CPU:** `min: 3.40%`, `max: 135.80%` +- **🧠Memory:** `min: 3.94%`, `max: 5.76%` +- **💽 System Disk:** `min: 31.60%`, `max: 37.09%` +- **🐳 Docker Disk:** `min: 27.93%`, `max: 27.93%` --- ```mermaid -gitGraph: - commit "Ashish" - branch newbranch - checkout newbranch - commit id:"1111" - commit tag:"tessst" - checkout main - commit type: HIGHLIGHT - commit - merge newbranch - commit - branch b2 - commit +xychart-beta +title "CPU Usage" +x-axis ["00:00", "00:01", "00:02", "00:04", "00:05", "00:06", "00:07", "00:08", "00:09", "00:10", "00:11", "00:12", "00:13", "00:14", "00:15", "00:16", "00:17", "00:18", "00:19", "00:20", "00:21", "00:22", "00:23", "00:24", "00:25", "00:26", "00:27", "00:29", "00:30", "00:31", "00:32", "00:33", "00:34", "00:35", "00:36", "00:37", "00:38", "00:39", "00:40", "00:41"] +y-axis "Usage (%)" +line [6.90, 12.00, 8.40, 25.10, 39.50, 44.30, 46.20, 47.90, 48.30, 43.50, 44.50, 46.40, 47.30, 47.90, 47.90, 10.20, 66.70, 3.70, 129.70, 135.80, 131.70, 129.70, 130.70, 129.70, 126.50, 122.40, 124.40, 126.50, 127.40, 128.40, 129.30, 130.30, 122.40, 123.30, 3.40, 3.40, 35.40, 23.40, 23.90, 3.40] +bar [6.90, 12.00, 8.40, 25.10, 39.50, 44.30, 46.20, 47.90, 48.30, 43.50, 44.50, 46.40, 47.30, 47.90, 47.90, 10.20, 66.70, 3.70, 129.70, 135.80, 131.70, 129.70, 130.70, 129.70, 126.50, 122.40, 124.40, 126.50, 127.40, 128.40, 129.30, 130.30, 122.40, 123.30, 3.40, 3.40, 35.40, 23.40, 23.90, 3.40] +``` + +```mermaid +xychart-beta +title "Memory Usage" +x-axis ["00:00", "00:01", "00:02", "00:04", "00:05", "00:06", "00:07", "00:08", "00:09", "00:10", "00:11", "00:12", "00:13", "00:14", "00:15", "00:16", "00:17", "00:18", "00:19", "00:20", "00:21", "00:22", "00:23", "00:24", "00:25", "00:26", "00:27", "00:29", "00:30", "00:31", "00:32", "00:33", "00:34", "00:35", "00:36", "00:37", "00:38", "00:39", "00:40", "00:41"] +y-axis "Usage (%)" +line [4.35, 4.36, 4.35, 4.34, 4.06, 4.22, 4.20, 4.21, 4.39, 4.72, 4.62, 4.75, 4.76, 4.65, 4.58, 5.76, 5.46, 5.35, 4.52, 4.51, 3.98, 4.08, 4.21, 3.96, 3.94, 4.11, 4.23, 4.07, 4.44, 4.15, 4.29, 4.05, 4.37, 4.57, 4.24, 4.72, 4.83, 4.81, 4.99, 4.84] +bar [4.35, 4.36, 4.35, 4.34, 4.06, 4.22, 4.20, 4.21, 4.39, 4.72, 4.62, 4.75, 4.76, 4.65, 4.58, 5.76, 5.46, 5.35, 4.52, 4.51, 3.98, 4.08, 4.21, 3.96, 3.94, 4.11, 4.23, 4.07, 4.44, 4.15, 4.29, 4.05, 4.37, 4.57, 4.24, 4.72, 4.83, 4.81, 4.99, 4.84] ``` + +```mermaid +xychart-beta +title "System Disk Usage" +x-axis ["00:00", "00:01", "00:02", "00:04", "00:05", "00:06", "00:07", "00:08", "00:09", "00:10", "00:11", "00:12", "00:13", "00:14", "00:15", "00:16", "00:17", "00:18", "00:19", "00:20", "00:21", "00:22", "00:23", "00:24", "00:25", "00:26", "00:27", "00:29", "00:30", "00:31", "00:32", "00:33", "00:34", "00:35", "00:36", "00:37", "00:38", "00:39", "00:40", "00:41"] +y-axis "Disk Usage (%)" +line [31.60, 31.60, 31.60, 31.61, 31.65, 31.69, 31.73, 31.77, 31.81, 31.85, 31.89, 31.93, 31.97, 32.01, 32.05, 32.43, 32.68, 32.68, 32.86, 33.09, 33.24, 33.31, 33.65, 34.07, 33.70, 33.92, 34.44, 34.44, 34.73, 35.18, 35.68, 35.73, 36.05, 36.48, 36.78, 37.04, 37.05, 37.05, 37.05, 37.09] +bar [31.60, 31.60, 31.60, 31.61, 31.65, 31.69, 31.73, 31.77, 31.81, 31.85, 31.89, 31.93, 31.97, 32.01, 32.05, 32.43, 32.68, 32.68, 32.86, 33.09, 33.24, 33.31, 33.65, 34.07, 33.70, 33.92, 34.44, 34.44, 34.73, 35.18, 35.68, 35.73, 36.05, 36.48, 36.78, 37.04, 37.05, 37.05, 37.05, 37.09] +``` +```mermaid +xychart-beta +title "Docker Disk Usage" +x-axis ["00:00", "00:01", "00:02", "00:04", "00:05", "00:06", "00:07", "00:08", "00:09", "00:10", "00:11", "00:12", "00:13", "00:14", "00:15", "00:16", "00:17", "00:18", "00:19", "00:20", "00:21", "00:22", "00:23", "00:24", "00:25", "00:26", "00:27", "00:29", "00:30", "00:31", "00:32", "00:33", "00:34", "00:35", "00:36", "00:37", "00:38", "00:39", "00:40", "00:41"] +y-axis "Disk Usage (%)" +line [27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93] +bar [27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93, 27.93] +``` \ No newline at end of file diff --git a/front/priv/storage/reports/wf_report.md b/front/priv/storage/reports/wf_report.md index e72b25695..af2ac2816 100644 --- a/front/priv/storage/reports/wf_report.md +++ b/front/priv/storage/reports/wf_report.md @@ -1,3 +1,179 @@ -# Workflow Report +# Security requirements +This markdown should be safely rendered to keep compliance with [project-tasks/issues#2650](https://github.com/renderedtext/project-tasks/issues/2650) -This is a test workflow report. +# Domain stats + +## Security Test - Safe Links and Potential XSS Attempts (for testing sanitization) + +These are safe test cases to verify sanitization is working: + +1. [Normal link to GitHub](https://github.com) +2. [Link with onclick attempt](https://example.com" onclick="alert('XSS')) +3. JavaScript protocol test +4. Data URL test +5. [Regular markdown link](https://www.example.com) +6. Link with onclick attribute +7. VBScript protocol test + +## Test - Mermaid Injection Attempts + +### Attempt 1: Breaking out with closing tags +```mermaid +graph TD + A[Start] --> B[Process] + B --> C[End
] +``` + +### Attempt 2: Using mermaid syntax with HTML-like content +```mermaid +graph LR + A[""] --> B[Next] + C[Test] --> D[End] +``` + +### Attempt 3: Classic mermaid escape attempt +mermaid + + +
+ + +
+ + +### Attempt 4: Using click events (valid mermaid syntax) + +```mermaid +graph TD + A[Click me - should be clickable but no XSS] -->|click| B[Process] + click A "javascript:alert('XSS')" + click B href "javascript:void(0)" "Tooltip" + click A "https://example.com" "This safe link should work" +``` + +### Attempt 5: Subgraph with HTML injection +```mermaid +graph TD + subgraph "" + A[Node A] + end + subgraph "Test" + B[Node B] + end + A --> B +``` + +### Attempt 6: Class definitions with malicious content +```mermaid +classDiagram + class BankAccount{ + +String owner + +BigDecimal balance + +deposit(amount) bool + +withdrawal + } + class Evil { + <> + +hack() + } +``` + +### Attempt 7: Sequence diagram with injections +```mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + A->>B: Hello + Note over A,B:
+``` + +### Attempt 8: Gantt chart with HTML +```mermaid +gantt + title A Gantt Diagram + dateFormat YYYY-MM-DD + section Section + A task :a1, 2014-01-01, 30d + Another task :after a1, 20d +``` + + +### Attempt 10: State diagram with onclick +```mermaid +stateDiagram-v2 + [*] --> First: Start + First --> Second: GoClick + Second --> [*]: End +``` +### Attempt 11: The original Cure53 vulnerability pattern +```mermaid +graph TD + A[Start] --> B[Middle] +``` + +```mermaid + + +
+ + +
+
+```
+
+### Attempt 12: Using link styling in flowchart
+```mermaid
+flowchart LR
+    A[Start] --> B{Is it working?}
+    B -->|Yes| C[Great]
+    B -->|No| D[Debug]
+
+    style A fill:#f9f,stroke:#333,stroke-width:4px,onclick:alert('styled')
+
+    click A "javascript:alert('XSS')" _blank
+    click B "data:text/html,"
+```
+
+## Test from https://github.com/renderedtext/project-tasks/issues/2650
+
+```mermaid
+
+ +
+ + +
+``` \ No newline at end of file diff --git a/front/test/browser/report_security_test.exs b/front/test/browser/report_security_test.exs deleted file mode 100644 index a449f3ee7..000000000 --- a/front/test/browser/report_security_test.exs +++ /dev/null @@ -1,516 +0,0 @@ -defmodule Front.Browser.ReportSecurityTest do - use FrontWeb.WallabyCase - - alias Support.Stubs - - import Wallaby.Query, only: [css: 1, css: 2, link: 1, xpath: 1] - - describe "Markdown Report Security" do - setup do - user = Stubs.User.create_default() - org = Stubs.Organization.create_default() - Support.Stubs.Feature.enable_feature(org.id, :job_reports) - Support.Stubs.Feature.enable_feature(org.id, :workflow_reports) - Support.Stubs.PermissionPatrol.allow_everything(org.id, user.id) - - project = Stubs.Project.create(org, user) - branch = Stubs.Branch.create(project) - hook = Stubs.Hook.create(branch) - workflow = Stubs.Workflow.create(hook, user) - - pipeline = - Stubs.Pipeline.create_initial(workflow, name: "Build & Test", organization_id: org.id) - - block = Stubs.Block.create(pipeline) - job = Stubs.Job.create(block) - - {:ok, - %{ - user: user, - org: org, - project: project, - workflow: workflow, - pipeline: pipeline, - job: job - }} - end - - test "XSS Prevention - javascript: protocol in links should be sanitized", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - [Click me](javascript:alert('XSS')) - Direct JS link - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> assert_no_js_protocol_links() - |> refute_has(css("a[href^='javascript:']")) - end - - test "XSS Prevention - data: URLs with scripts should be blocked", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - Data URL XSS - [Data URL](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=) - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("a[href^='data:']")) - end - - test "XSS Prevention - onclick and event handlers should be removed", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - Link with onclick - Link with onmouseover -
Div with onclick
- - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> assert_no_event_handlers() - end - - test "CSRF Prevention - form elements should be completely blocked", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report -
- - -
- """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("form")) - |> refute_has(css("input")) - end - - test "CSS Injection Prevention - style tags should be blocked", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("style")) - end - - test "Script Injection Prevention - script tags should be blocked", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - - - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("script")) - end - - test "Dangerous Elements - iframe, embed, object should be blocked", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - - - - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("iframe")) - |> refute_has(css("embed")) - |> refute_has(css("object")) - end - - test "VBScript Protocol - should be sanitized", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - VBScript XSS - """ - - Stubs.Artifact.create_job_report(job.id, malicious_content) - - session - |> visit("/jobs/#{job.id}/reports") - |> refute_has(css("a[href^='vbscript:']")) - end - - test "Safe Links - HTTPS links should be allowed and have security attributes", %{ - session: session, - job: job - } do - safe_content = """ - # Test Report - [GitHub](https://github.com) - [Example](https://example.com) - Safe Link - """ - - Stubs.Artifact.create_job_report(job.id, safe_content) - - page = - session - |> visit("/jobs/#{job.id}/reports") - - # Check that safe links are present - assert_has(page, css("a[href='https://github.com']")) - assert_has(page, css("a[href='https://example.com']")) - assert_has(page, css("a[href='https://safe-site.com']")) - - # Check security attributes - page - |> assert_link_security_attributes() - end - - test "Safe Links - mailto links should be allowed", %{ - session: session, - job: job - } do - content = """ - # Test Report - [Email Us](mailto:test@example.com) - """ - - Stubs.Artifact.create_job_report(job.id, content) - - session - |> visit("/jobs/#{job.id}/reports") - |> assert_has(css("a[href^='mailto:']")) - end - - test "Mermaid Diagrams - should not allow HTML injection", %{ - session: session, - job: job - } do - content = """ - # Test Report - ```mermaid - graph TD - A[Start] --> B{Is it?} - B -->|Yes| C[OK] - B -->|No| D[End] - ``` - """ - - Stubs.Artifact.create_job_report(job.id, content) - - page = - session - |> visit("/jobs/#{job.id}/reports") - - # Mermaid diagrams should be rendered in sandboxed iframes - assert_has(page, css(".mermaid")) - - # Ensure no script injection in mermaid content - refute_mermaid_has_scripts(page) - end - - test "Mermaid click directives - should allow clicks but sanitize javascript: URLs", %{ - session: session, - job: job - } do - content = """ - # Test Report - ```mermaid - graph TD - A[Clickable Node] --> B[Another Node] - click A "javascript:alert('XSS')" - click B "https://safe-site.com" - ``` - """ - - Stubs.Artifact.create_job_report(job.id, content) - - page = - session - |> visit("/jobs/#{job.id}/reports") - - # Check that mermaid rendered - assert_has(page, css(".mermaid")) - - # Verify that javascript: URLs are sanitized in click handlers - page - |> execute_script(""" - const mermaidElements = document.querySelectorAll('.mermaid a, .mermaid [onclick]'); - for (let el of mermaidElements) { - if (el.href && el.href.startsWith('javascript:')) return true; - if (el.onclick && el.onclick.toString().includes('alert')) return true; - } - return false; - """) - |> then(fn result -> refute result, "JavaScript URLs should be sanitized in Mermaid click handlers" end) - - # But safe URLs should still work - page - |> execute_script(""" - const mermaidElements = document.querySelectorAll('.mermaid a'); - return Array.from(mermaidElements).some(el => - el.href && el.href.includes('safe-site.com') - ); - """) - |> then(fn result -> assert result, "Safe URLs in Mermaid click directives should be preserved" end) - end - - test "Mermaid Diagrams - malformed syntax with script injection attempts", %{ - session: session, - job: job - } do - malicious_content = """ - # Test Report - ```mermaid - - -
- -
-
-      ```
-      """
-
-      Stubs.Artifact.create_job_report(job.id, malicious_content)
-
-      session
-      |> visit("/jobs/#{job.id}/reports")
-      |> refute_has(css("script"))
-      |> refute_has(css("form"))
-    end
-
-    test "Content Preservation - safe markdown should be rendered correctly", %{
-      session: session,
-      job: job
-    } do
-      safe_content = """
-      # Main Title
-      ## Subtitle
-      **Bold text** and *italic text*
-      - List item 1
-      - List item 2
-
-      | Column 1 | Column 2 |
-      |----------|----------|
-      | Data 1   | Data 2   |
-
-      ```javascript
-      const safe = "code block";
-      ```
-
-      
- Click to expand - This is hidden content -
- """ - - Stubs.Artifact.create_job_report(job.id, safe_content) - - page = - session - |> visit("/jobs/#{job.id}/reports") - - # Check all safe elements are preserved - assert_has(page, css("h1")) - assert_has(page, css("h2")) - assert_has(page, css("strong")) - assert_has(page, css("em")) - assert_has(page, css("ul")) - assert_has(page, css("table")) - assert_has(page, css("code")) - assert_has(page, css("details")) - assert_has(page, css("summary")) - - # Check content is preserved - assert_text(page, "Main Title") - assert_text(page, "Bold text") - assert_text(page, "List item 1") - assert_text(page, "Data 1") - end - - test "Mixed Content - safe content preserved, unsafe removed", %{ - session: session, - job: job - } do - mixed_content = """ - # Safe Title - - This is safe text - Unsafe link - [Safe link](https://example.com) -
Unsafe form
- **Safe bold text** - """ - - Stubs.Artifact.create_job_report(job.id, mixed_content) - - page = - session - |> visit("/jobs/#{job.id}/reports") - - # Safe content should be present - assert_text(page, "Safe Title") - assert_text(page, "This is safe text") - assert_text(page, "Safe bold text") - assert_has(page, css("h1")) - assert_has(page, css("strong")) - assert_has(page, css("a[href='https://example.com']")) - - # Unsafe content should be removed - refute_has(page, css("script")) - refute_has(page, css("form")) - refute_has(page, css("a[href*='javascript']")) - end - - test "Workflow Reports - same security rules apply", %{ - session: session, - workflow: workflow - } do - malicious_content = """ - # Workflow Report - -
- -
- Click - """ - - Stubs.Artifact.create_workflow_report(workflow.id, malicious_content) - - session - |> visit("/workflows/#{workflow.id}/reports") - |> refute_has(css("script")) - |> refute_has(css("form")) - |> refute_has(css("input")) - |> refute_has(css("a[href^='javascript:']")) - end - - test "Project Reports - same security rules apply", %{ - session: session, - project: project - } do - malicious_content = """ - # Project Report - - - Data URL - """ - - Stubs.Artifact.create_project_report(project.id, malicious_content) - - session - |> visit("/projects/#{project.name}/reports") - |> refute_has(css("style")) - |> refute_has(css("iframe")) - |> refute_has(css("a[href^='data:']")) - end - end - - # Helper functions - - defp assert_no_js_protocol_links(session) do - session - |> execute_script(""" - return Array.from(document.querySelectorAll('a')).some(link => - link.href && link.href.startsWith('javascript:') - ); - """) - |> then(fn result -> refute result end) - - session - end - - defp assert_no_event_handlers(session) do - session - |> execute_script(""" - const elements = document.querySelectorAll('*'); - for (let el of elements) { - if (el.onclick || el.onmouseover || el.onerror || el.onload) { - return true; - } - } - return false; - """) - |> then(fn result -> refute result end) - - session - end - - defp assert_link_security_attributes(session) do - session - |> execute_script(""" - const links = document.querySelectorAll('a[href^="https://"]'); - return Array.from(links).every(link => - link.target === '_blank' && - link.rel && link.rel.includes('noopener') && link.rel.includes('noreferrer') - ); - """) - |> then(fn result -> assert result end) - - session - end - - defp refute_mermaid_has_scripts(session) do - session - |> execute_script(""" - const mermaidElements = document.querySelectorAll('.mermaid'); - for (let el of mermaidElements) { - if (el.innerHTML.includes(' then(fn result -> refute result end) - - session - end -end \ No newline at end of file From 129c5adaa1fbf3dcbaa5a40e29e65bb721364c4d Mon Sep 17 00:00:00 2001 From: hamir-suspect Date: Tue, 30 Sep 2025 13:57:43 +0200 Subject: [PATCH 3/3] fix: linting --- front/assets/js/report/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/front/assets/js/report/index.tsx b/front/assets/js/report/index.tsx index 95ff10cb4..02f71bc12 100644 --- a/front/assets/js/report/index.tsx +++ b/front/assets/js/report/index.tsx @@ -90,11 +90,11 @@ const MarkdownBody = (props: { markdown: string, }) => { // 1. It uses DOMPurify internally for sanitizing diagram content // 2. With securityLevel: 'sandbox' configured above, it renders each diagram in a sandboxed iframe // First, configure DOMPurify hooks - DOMPurify.addHook('afterSanitizeAttributes', function(node) { + DOMPurify.addHook(`afterSanitizeAttributes`, function(node) { // Force all links to open in new tab with security attributes - if ('tagName' in node && node.tagName === 'A') { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer nofollow'); + if (`tagName` in node && node.tagName === `A`) { + node.setAttribute(`target`, `_blank`); + node.setAttribute(`rel`, `noopener noreferrer nofollow`); } }); @@ -121,7 +121,7 @@ const MarkdownBody = (props: { markdown: string, }) => { `title`, `open`, `class`, - `href`, // DOMPurify by default blocks dangerous protocols (javascript:, data:, vbscript:) and only allows safe ones (http:, https:, mailto:, etc.) + `href`, // DOMPurify by default blocks dangerous protocols (javascript:, data:, vbscript:) and only allows safe ones (http:, https:, mailto:, etc.) `target`, `rel` ], @@ -134,7 +134,7 @@ const MarkdownBody = (props: { markdown: string, }) => { }); // Clean up the hook after sanitization to avoid memory leaks - DOMPurify.removeHook('afterSanitizeAttributes'); + DOMPurify.removeHook(`afterSanitizeAttributes`); return (