From e7403c5a164a2608832971ad3ee75e1b80b8a75e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 4 Mar 2026 19:24:42 +0100 Subject: [PATCH 1/8] chore(deps): replace bleach with nh3 for HTML sanitization bleach is deprecated and archived. nh3 is its Rust-backed successor, actively maintained and significantly faster. --- dojo/templatetags/display_tags.py | 37 +++++++++++++------------------ dojo/templatetags/get_banner.py | 16 ++++--------- dojo/utils.py | 4 ++-- requirements.txt | 3 +-- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/dojo/templatetags/display_tags.py b/dojo/templatetags/display_tags.py index acd7e3ea944..4c34f0c8a94 100644 --- a/dojo/templatetags/display_tags.py +++ b/dojo/templatetags/display_tags.py @@ -8,10 +8,9 @@ from itertools import chain from pathlib import Path -import bleach import dateutil.relativedelta import markdown -from bleach.css_sanitizer import CSSSanitizer +import nh3 from django import template from django.conf import settings from django.contrib.auth.models import User @@ -51,17 +50,21 @@ } markdown_attrs = { - "*": ["id"], - "img": ["src", "alt", "title", "width", "height", "style"], - "a": ["href", "alt", "target", "title"], - "span": ["class"], # used for code highlighting - "pre": ["class"], # used for code highlighting - "div": ["class"], # used for code highlighting + "*": {"id"}, + "img": {"src", "alt", "title", "width", "height"}, + "a": {"href", "alt", "target", "title"}, + "span": {"class"}, # used for code highlighting + "pre": {"class"}, # used for code highlighting + "div": {"class"}, # used for code highlighting } -markdown_styles = [ - "background-color", -] +# nh3 base allowlist (equivalent to bleach.ALLOWED_TAGS / bleach.ALLOWED_ATTRIBUTES) +_NH3_ALLOWED_TAGS = {"a", "abbr", "acronym", "b", "blockquote", "code", "em", "i", "li", "ol", "strong", "ul"} +_NH3_ALLOWED_ATTRIBUTES = { + "a": {"href", "title", "target"}, + "abbr": {"title"}, + "acronym": {"title"}, +} finding_related_action_classes_dict = { "reset_finding_duplicate_status": "fa-solid fa-eraser", @@ -92,21 +95,13 @@ def markdown_render(value): "markdown.extensions.fenced_code", "markdown.extensions.toc", "markdown.extensions.tables"]) - return mark_safe(bleach.clean(markdown_text, tags=markdown_tags, attributes=markdown_attrs, css_sanitizer=CSSSanitizer(allowed_css_properties=markdown_styles))) + return mark_safe(nh3.clean(markdown_text, tags=markdown_tags, attributes=markdown_attrs)) return None @register.filter def bleach_with_a_tags(message): - # Create a copy of ALLOWED_ATTRIBUTES to avoid mutating the global - allowed_attributes = { - **bleach.ALLOWED_ATTRIBUTES, - "a": [*bleach.ALLOWED_ATTRIBUTES.get("a", []), "style", "target"], - } - return mark_safe(bleach.clean( - message, - attributes=allowed_attributes, - css_sanitizer=CSSSanitizer(allowed_css_properties=["color", "font-weight"]))) + return mark_safe(nh3.clean(message, tags=_NH3_ALLOWED_TAGS, attributes=_NH3_ALLOWED_ATTRIBUTES)) def text_shortener(value, length): diff --git a/dojo/templatetags/get_banner.py b/dojo/templatetags/get_banner.py index 321e7101604..6e7cbae0881 100644 --- a/dojo/templatetags/get_banner.py +++ b/dojo/templatetags/get_banner.py @@ -1,9 +1,9 @@ -import bleach -from bleach.css_sanitizer import CSSSanitizer +import nh3 from django import template from django.utils.safestring import mark_safe from dojo.models import BannerConf +from dojo.templatetags.display_tags import _NH3_ALLOWED_ATTRIBUTES, _NH3_ALLOWED_TAGS register = template.Library() @@ -15,16 +15,8 @@ def get_banner_conf(attribute): value = getattr(banner_config, attribute, None) if value: if attribute == "banner_message": - # only admin can edit login banner, so we allow html, but still bleach it - # Create a copy of ALLOWED_ATTRIBUTES to avoid mutating the global - allowed_attributes = { - **bleach.ALLOWED_ATTRIBUTES, - "a": [*bleach.ALLOWED_ATTRIBUTES.get("a", []), "style", "target"], - } - return mark_safe(bleach.clean( - value, - attributes=allowed_attributes, - css_sanitizer=CSSSanitizer(allowed_css_properties=["color", "font-weight"]))) + # only admin can edit login banner, so we allow html, but still sanitize it + return mark_safe(nh3.clean(value, tags=_NH3_ALLOWED_TAGS, attributes=_NH3_ALLOWED_ATTRIBUTES)) return value except Exception: return False diff --git a/dojo/utils.py b/dojo/utils.py index 68fda98a281..a710cdbe9aa 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -17,9 +17,9 @@ from math import pi, sqrt from pathlib import Path -import bleach import crum import cvss +import nh3 import redis as redis_lib import vobject from amqp.exceptions import ChannelError @@ -1539,7 +1539,7 @@ def create_bleached_link(url, title): link += '">' link += title link += "" - return bleach.clean(link, tags={"a"}, attributes={"a": ["href", "target", "title"]}) + return nh3.clean(link, tags={"a"}, attributes={"a": {"href", "target", "title"}}) def get_object_or_none(klass, *args, **kwargs): diff --git a/requirements.txt b/requirements.txt index ac5c98fea4c..fc4f673bd9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ # requirements.txt for DefectDojo using Python 3.x -bleach==6.3.0 -bleach[css] +nh3 celery[sqs]==5.6.3 # pycurl and boto3 are included via celery[sqs] for Celery Broker AWS (SQS) support defusedxml==0.7.1 From 2936c9c84cfe5edea1e2db1ab8908ab5625a3222 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 4 Mar 2026 19:39:10 +0100 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20add=202.57=20upgrade=20note=20for?= =?UTF-8?q?=20bleach=20=E2=86=92=20nh3=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/open_source/upgrading/2.57.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/content/en/open_source/upgrading/2.57.md b/docs/content/en/open_source/upgrading/2.57.md index aeb39538930..2a31552c08d 100644 --- a/docs/content/en/open_source/upgrading/2.57.md +++ b/docs/content/en/open_source/upgrading/2.57.md @@ -2,6 +2,13 @@ title: 'Upgrading to DefectDojo Version 2.57.x' toc_hide: true weight: -20260302 -description: No special instructions. +description: HTML sanitization library replaced (bleach → nh3). --- -There are no special instructions for upgrading to 2.57.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.57.0) for the contents of the release. +Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/2.57.0) for the contents of the release. + +## HTML sanitization: bleach replaced by nh3 + +The `bleach` library has been replaced by [`nh3`](https://nh3.readthedocs.io/) for HTML sanitization. This is a drop-in replacement in most cases, but there are two minor behavioral changes to be aware of: + +- **`style` attributes are no longer allowed.** `bleach` supported CSS property-level filtering (e.g. allowing only `color` or `font-weight`). `nh3` has no equivalent, so `style` attributes are stripped entirely to avoid allowing arbitrary CSS injection. Content that previously relied on inline styles (e.g. colored text in the login banner, background-color on markdown images) will lose that styling. +- **Disallowed tags are stripped rather than escaped.** Previously, a tag like `" engagement.source_code_management_uri = "" - self.assertEqual('<SCRIPT SRC=http://xss.rocks/xss.js></SCRIPT>', finding.get_file_path_with_link()) + result = finding.get_file_path_with_link() + # XSS payloads must be escaped — no raw tags should appear in the output + self.assertNotIn("https://www.example.com', finding.get_references_with_links()) + self.assertEqual('URL: https://www.example.com', finding.get_references_with_links()) def test_get_references_with_links_url_with_port(self): finding = Finding() finding.references = "http://www.example.com:8080" - self.assertEqual('http://www.example.com:8080', finding.get_references_with_links()) + self.assertEqual('http://www.example.com:8080', finding.get_references_with_links()) def test_get_references_with_links_url_with_path(self): finding = Finding() finding.references = "URL https://www.example.com/path/part2 behind URL" - self.assertEqual('URL https://www.example.com/path/part2 behind URL', finding.get_references_with_links()) + self.assertEqual('URL https://www.example.com/path/part2 behind URL', finding.get_references_with_links()) def test_get_references_with_links_complicated_url_with_parameter(self): finding = Finding() finding.references = "URL:https://www.example.com/path?param1=abc&_param2=xyz" - self.assertEqual('URL:https://www.example.com/path?param1=abc&_param2=xyz', finding.get_references_with_links()) + self.assertEqual('URL:https://www.example.com/path?param1=abc&_param2=xyz', finding.get_references_with_links()) def test_get_references_with_links_two_urls(self): finding = Finding() finding.references = "URL1: https://www.example.com URL2: https://info.example.com" - self.assertEqual('URL1: https://www.example.com URL2: https://info.example.com', finding.get_references_with_links()) + self.assertEqual('URL1: https://www.example.com URL2: https://info.example.com', finding.get_references_with_links()) def test_get_references_with_links_linebreak(self): finding = Finding() finding.references = "https://www.example.com\nhttps://info.example.com" - self.assertEqual('https://www.example.com\nhttps://info.example.com', finding.get_references_with_links()) + self.assertEqual('https://www.example.com\nhttps://info.example.com', finding.get_references_with_links()) def test_get_references_with_links_markdown(self): finding = Finding() From ec2a0f6ae804a47516ca30b80b1f47ec0cf7f47d Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 6 Mar 2026 18:57:03 +0100 Subject: [PATCH 5/8] fix: use escape() directly in create_bleached_link, drop nh3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nh3/ammonia does not re-escape < in attribute values when re-serializing, so passing escape()'d HTML through nh3.clean() still produced raw angle brackets in href/title. The function constructs trusted HTML itself, so nh3 is redundant here — escape() is sufficient and correct. Also adds rel="noopener noreferrer" explicitly and updates tests to match the new output including the exact XSS-escaped form. --- dojo/utils.py | 6 ++---- unittests/test_finding_model.py | 12 +++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dojo/utils.py b/dojo/utils.py index 28c3371ec8e..041d0de9433 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -19,7 +19,6 @@ import crum import cvss -import nh3 import redis as redis_lib import vobject from amqp.exceptions import ChannelError @@ -1533,9 +1532,8 @@ def get_current_request(): def create_bleached_link(url, title): - # nh3 requires an explicit escape - link = f'{escape(title)}' - return nh3.clean(link, tags={"a"}, attributes={"a": {"href", "target", "title"}}) + # escape() encodes text into HTML — the right tool for embedding URLs into attributes, not a sanitizer like nh3 + return f'{escape(title)}' def get_object_or_none(klass, *args, **kwargs): diff --git a/unittests/test_finding_model.py b/unittests/test_finding_model.py index 3b3f03b8d83..6aaaa3c11ef 100644 --- a/unittests/test_finding_model.py +++ b/unittests/test_finding_model.py @@ -334,11 +334,13 @@ def test_get_file_path_with_xss_attack(self): finding.test = test finding.file_path = "" engagement.source_code_management_uri = "" - result = finding.get_file_path_with_link() - # XSS payloads must be escaped — no raw tags should appear in the output - self.assertNotIn("<SCRIPT SRC=http://xss.rocks/xss.js></SCRIPT>', + finding.get_file_path_with_link()) def test_get_references_with_links_no_references(self): finding = Finding() From f416d7f85480bfa8ab4f8751be5dd2bf6999f949 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 16 Jun 2026 16:38:16 +0200 Subject: [PATCH 6/8] fix(bleach-to-nh3): replace bleach with nh3 in api_sonarqube importer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed file in initial bleach→nh3 migration. Maps bleach.clean() params to nh3.clean() equivalents: protocols→url_schemes, lists→sets. Co-Authored-By: Claude Sonnet 4.6 --- dojo/tools/api_sonarqube/importer.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/dojo/tools/api_sonarqube/importer.py b/dojo/tools/api_sonarqube/importer.py index d0ae635cfa0..972b0e6ceab 100644 --- a/dojo/tools/api_sonarqube/importer.py +++ b/dojo/tools/api_sonarqube/importer.py @@ -2,9 +2,9 @@ import re import textwrap -import bleach import html2text import markdown +import nh3 from django.conf import settings from django.core.exceptions import ValidationError from lxml import etree @@ -420,12 +420,11 @@ def sanitize_rule_details(description): description, flags=re.DOTALL | re.IGNORECASE, ) - return bleach.clean( + return nh3.clean( sanitized_description, - tags=SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_TAGS, - attributes=SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_ATTRIBUTES, - protocols=SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_PROTOCOLS, - strip=True, + tags=set(SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_TAGS), + attributes={k: set(v) for k, v in SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_ATTRIBUTES.items()}, + url_schemes=set(SonarQubeApiImporter.ALLOWED_RULE_DESCRIPTION_PROTOCOLS), ) @staticmethod From 4f8197a11530e2cf7ad77c5d1b554b8ae5019415 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 16 Jun 2026 20:22:17 +0200 Subject: [PATCH 7/8] fix(bleach-to-nh3): migrate os_message.py and fix test assertions for nh3 - Replace bleach with nh3 in dojo/announcement/os_message.py (was causing ModuleNotFoundError in 132 rest-framework tests) - Update sonarqube importer test assertions to match nh3 output, which adds rel="noopener noreferrer" to all links Co-Authored-By: Claude Sonnet 4.6 --- dojo/announcement/os_message.py | 16 +++++++--------- unittests/tools/test_api_sonarqube_importer.py | 8 ++++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/dojo/announcement/os_message.py b/dojo/announcement/os_message.py index dfdb9288710..d1dde8c8800 100644 --- a/dojo/announcement/os_message.py +++ b/dojo/announcement/os_message.py @@ -1,7 +1,7 @@ import logging -import bleach import markdown +import nh3 import requests from django.core.cache import cache @@ -75,11 +75,10 @@ def parse_os_message(text): headline_source = headline_source[:100] headline_rendered = markdown.markdown(headline_source) - headline_cleaned = bleach.clean( + headline_cleaned = nh3.clean( headline_rendered, - tags=INLINE_TAGS, - attributes=INLINE_ATTRS, - strip=True, + tags=set(INLINE_TAGS), + attributes={k: set(v) for k, v in INLINE_ATTRS.items()}, ) headline_html = _strip_outer_p(headline_cleaned) @@ -98,11 +97,10 @@ def parse_os_message(text): expanded_source, extensions=["extra", "fenced_code", "nl2br"], ) - expanded_html = bleach.clean( + expanded_html = nh3.clean( expanded_rendered, - tags=BLOCK_TAGS, - attributes=BLOCK_ATTRS, - strip=True, + tags=set(BLOCK_TAGS), + attributes={k: set(v) for k, v in BLOCK_ATTRS.items()}, ) return {"message": headline_html, "expanded_html": expanded_html} diff --git a/unittests/tools/test_api_sonarqube_importer.py b/unittests/tools/test_api_sonarqube_importer.py index 8dd3b9eafa0..4f3b83a5eb4 100644 --- a/unittests/tools/test_api_sonarqube_importer.py +++ b/unittests/tools/test_api_sonarqube_importer.py @@ -351,8 +351,8 @@ def test_get_rule_details_sanitizes_markdown_html(self): ) self.assertIn("

Heading

", rule_details) - self.assertIn('safe', rule_details) - self.assertIn("unsafe", rule_details) + self.assertIn('safe', rule_details) + self.assertIn('unsafe', rule_details) self.assertNotIn("References", rule_details) - self.assertIn('OWASP', rule_details) - self.assertIn("unsafe", rule_details) + self.assertIn('OWASP', rule_details) + self.assertIn('unsafe', rule_details) self.assertNotIn(" Date: Tue, 16 Jun 2026 21:58:00 +0200 Subject: [PATCH 8/8] fix(bleach-to-nh3): update os_message test assertion for nh3 link output nh3 adds rel="noopener noreferrer" to links; update assertion to match. Co-Authored-By: Claude Sonnet 4.6 --- unittests/test_os_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unittests/test_os_message.py b/unittests/test_os_message.py index 3b43b1e3de5..813e49e0c85 100644 --- a/unittests/test_os_message.py +++ b/unittests/test_os_message.py @@ -43,7 +43,7 @@ def test_headline_inline_markdown(self): text = "# Read the **release notes** at [link](https://example.com)\n" result = os_message.parse_os_message(text) self.assertIn("release notes", result["message"]) - self.assertIn('link', result["message"]) + self.assertIn('link', result["message"]) self.assertIsNone(result["expanded_html"]) def test_headline_strips_disallowed_html(self):