From 79259ffb959901208e6906726e1db26aac5d6d6b Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Thu, 25 Jun 2026 20:21:22 -0600 Subject: [PATCH 1/5] feat(announcement): add DD_OS_MESSAGE_ENABLED and per-user dismiss for the OS promo banner DD_OS_MESSAGE_ENABLED (bool, default True) is a global admin opt-out: when False, get_os_banner() returns None before any network call, so no request is made to the GCS bucket. Default behavior is unchanged. Authenticated users can also dismiss the banner via a close (x) button. The dismissal is stored as a hash of the current message on UserContactInfo (mirroring the ui_use_tailwind preference), so the banner reappears only when the promo text changes. The button posts to a new dismiss_os_message endpoint (form-based CSRF) and hides the banner instantly via JS, degrading to a normal POST when JS is disabled. Includes migration 0270, docs and template-env updates, and unit tests (36 passing in unittests/test_os_message.py). Co-Authored-By: Claude Opus 4.8 --- docs/content/en/open_source/upgrading/3.1.md | 8 +- dojo/announcement/os_message.py | 10 +- dojo/announcement/urls.py | 5 + dojo/announcement/views.py | 27 ++++- dojo/context_processors.py | 27 +++-- ...ercontactinfo_os_message_dismissed_hash.py | 18 +++ dojo/models.py | 1 + dojo/settings/settings.dist.py | 2 + dojo/settings/template-env | 3 + dojo/static/dojo/js/classic/index.js | 13 +++ dojo/static/dojo/js/index.js | 22 ++++ dojo/templates/base.html | 9 ++ dojo/templates_classic/base.html | 9 ++ unittests/test_os_message.py | 104 ++++++++++++++++++ 14 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py diff --git a/docs/content/en/open_source/upgrading/3.1.md b/docs/content/en/open_source/upgrading/3.1.md index bc1265d40c2..540140c7aba 100644 --- a/docs/content/en/open_source/upgrading/3.1.md +++ b/docs/content/en/open_source/upgrading/3.1.md @@ -2,6 +2,10 @@ title: 'Upgrading to DefectDojo Version 3.1.x' toc_hide: true weight: -20260615 -description: No special instructions. +description: New optional setting DD_OS_MESSAGE_ENABLED to control the open-source promo banner. --- -There are no special instructions for upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release. +There are no breaking changes when upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release. + +### New setting: `DD_OS_MESSAGE_ENABLED` + +This release adds the `DD_OS_MESSAGE_ENABLED` setting (default `True`), which controls the open-source promotional ("Upgrade to Pro") banner. The default preserves the existing behavior. Set `DD_OS_MESSAGE_ENABLED=False` to hide the banner; when disabled, DefectDojo skips the outbound request that fetches the message. diff --git a/dojo/announcement/os_message.py b/dojo/announcement/os_message.py index dfdb9288710..3ea5052f7f5 100644 --- a/dojo/announcement/os_message.py +++ b/dojo/announcement/os_message.py @@ -1,8 +1,10 @@ +import hashlib import logging import bleach import markdown import requests +from django.conf import settings from django.core.cache import cache logger = logging.getLogger(__name__) @@ -109,11 +111,17 @@ def parse_os_message(text): def get_os_banner(): + if not settings.OS_MESSAGE_ENABLED: + return None try: text = fetch_os_message() if not text: return None - return parse_os_message(text) + banner = parse_os_message(text) except Exception: logger.debug("os_message: get_os_banner failed", exc_info=True) return None + else: + if banner: + banner["dismiss_token"] = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + return banner diff --git a/dojo/announcement/urls.py b/dojo/announcement/urls.py index 9dc91187653..07e79801045 100644 --- a/dojo/announcement/urls.py +++ b/dojo/announcement/urls.py @@ -13,4 +13,9 @@ views.dismiss_announcement, name="dismiss_announcement", ), + re_path( + r"^dismiss_os_message$", + views.dismiss_os_message, + name="dismiss_os_message", + ), ] diff --git a/dojo/announcement/views.py b/dojo/announcement/views.py index 7afe915210b..2fd0eba02b0 100644 --- a/dojo/announcement/views.py +++ b/dojo/announcement/views.py @@ -1,14 +1,17 @@ import logging +import re from django.contrib import messages -from django.http import HttpResponseRedirect +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST from dojo.forms import AnnouncementCreateForm, AnnouncementRemoveForm -from dojo.models import Announcement, UserAnnouncement +from dojo.models import Announcement, UserAnnouncement, UserContactInfo from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) @@ -85,3 +88,23 @@ def dismiss_announcement(request): ) return render(request, "dojo/dismiss_announcement.html") return render(request, "dojo/dismiss_announcement.html") + + +@require_POST +def dismiss_os_message(request): + if not request.user.is_authenticated: + return HttpResponseForbidden() + token = request.POST.get("token", "").strip() + if token and re.fullmatch(r"[0-9a-f]{1,64}", token): + contact = UserContactInfo.objects.get_or_create(user=request.user)[0] + if contact.os_message_dismissed_hash != token: + contact.os_message_dismissed_hash = token + contact.save(update_fields=["os_message_dismissed_hash"]) + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return HttpResponse(status=204) + referer = request.META.get("HTTP_REFERER") + if referer and url_has_allowed_host_and_scheme( + referer, allowed_hosts={request.get_host()}, require_https=request.is_secure(), + ): + return HttpResponseRedirect(referer) + return HttpResponseRedirect("/") diff --git a/dojo/context_processors.py b/dojo/context_processors.py index 561ae0d5791..75ce9e38021 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -28,14 +28,20 @@ def globalize_vars(request): additional_banners = [] if (os_banner := get_os_banner()) is not None: - additional_banners.append({ - "source": "os", - "message": os_banner["message"], - "style": "info", - "url": "", - "link_text": "", - "expanded_html": os_banner["expanded_html"], - }) + token = os_banner.get("dismiss_token", "") + user = getattr(request, "user", None) + dismissible = bool(token and getattr(user, "is_authenticated", False)) + if not (dismissible and _os_message_dismissed(user, token)): + additional_banners.append({ + "source": "os", + "message": os_banner["message"], + "style": "info", + "url": "", + "link_text": "", + "expanded_html": os_banner["expanded_html"], + "dismissible": dismissible, + "dismiss_token": token, + }) if hasattr(request, "session"): for banner in request.session.pop("_product_banners", []): @@ -72,6 +78,11 @@ def _should_show_ui_toggle_banner(request): return not (contact is not None and getattr(contact, "ui_use_tailwind", False)) +def _os_message_dismissed(user, token): + contact = getattr(user, "usercontactinfo", None) + return contact is not None and contact.os_message_dismissed_hash == token + + def bind_system_settings(request): """Load system settings and display warning if there's a database error.""" try: diff --git a/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py b/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py new file mode 100644 index 00000000000..239a559be1d --- /dev/null +++ b/dojo/db_migrations/0270_usercontactinfo_os_message_dismissed_hash.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-06-25 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0269_normalize_blank_finding_components'), + ] + + operations = [ + migrations.AddField( + model_name='usercontactinfo', + name='os_message_dismissed_hash', + field=models.CharField(blank=True, default='', editable=False, help_text='Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes.', max_length=64), + ), + ] diff --git a/dojo/models.py b/dojo/models.py index 7f798e23ae4..8de198110af 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -260,6 +260,7 @@ class UserContactInfo(models.Model): ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) + os_message_dismissed_hash = models.CharField(max_length=64, blank=True, default="", editable=False, help_text=_("Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes.")) class System_Settings(models.Model): diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 2d92588ad70..d0afa0a88db 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -147,6 +147,7 @@ DD_FORGOT_PASSWORD=(bool, True), # do we show link "I forgot my password" on login screen DD_PASSWORD_RESET_TIMEOUT=(int, 259200), # 3 days, in seconds (the deafult) DD_FORGOT_USERNAME=(bool, True), # do we show link "I forgot my username" on login screen + DD_OS_MESSAGE_ENABLED=(bool, True), # show the open-source "Upgrade to Pro" / OS message promo banner # Some security policies require allowing users to have only one active session DD_SINGLE_USER_SESSION=(bool, False), # if somebody is using own documentation how to use DefectDojo in his own company @@ -474,6 +475,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param REQUIRE_PASSWORD_ON_USER = env("DD_REQUIRE_PASSWORD_ON_USER") FORGOT_USERNAME = env("DD_FORGOT_USERNAME") PASSWORD_RESET_TIMEOUT = env("DD_PASSWORD_RESET_TIMEOUT") +OS_MESSAGE_ENABLED = env("DD_OS_MESSAGE_ENABLED") DOCUMENTATION_URL = env("DD_DOCUMENTATION_URL") diff --git a/dojo/settings/template-env b/dojo/settings/template-env index 0263dc941a7..2f633c769b4 100644 --- a/dojo/settings/template-env +++ b/dojo/settings/template-env @@ -47,6 +47,9 @@ DD_WHITENOISE=True # If True, the SecurityMiddleware sets the X-Content-Type-Options: nosniff; # DD_SECURE_CONTENT_TYPE_NOSNIFF=True +# Show the open-source promo ("Upgrade to Pro") banner. Set to False to disable. +# DD_OS_MESSAGE_ENABLED=True + # Change the default language set # DD_LANG=en-us diff --git a/dojo/static/dojo/js/classic/index.js b/dojo/static/dojo/js/classic/index.js index 4de7c38f63b..694f62dec1b 100644 --- a/dojo/static/dojo/js/classic/index.js +++ b/dojo/static/dojo/js/classic/index.js @@ -1,5 +1,18 @@ $(function () { $('body').append(''); + + // ---- OS promo banner dismiss: persist per-user (form carries CSRF) + hide instantly ---- + $(document).on('submit', '.os-message-dismiss-form', function (e) { + e.preventDefault(); + var form = this; + $(form).closest('.announcement-banner').fadeOut(200, function () { $(this).remove(); }); + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }); + }); $(window).scroll(function () { if ($(this).scrollTop() > 300) { $('#toTop').fadeIn(); diff --git a/dojo/static/dojo/js/index.js b/dojo/static/dojo/js/index.js index d9986d7b975..4f29c973685 100644 --- a/dojo/static/dojo/js/index.js +++ b/dojo/static/dojo/js/index.js @@ -65,6 +65,28 @@ }); })(); +/* ---- OS promo banner dismiss ---- + Persist the dismissal per-user (the form carries csrfmiddlewaretoken) and + hide the banner instantly. Degrades to a normal form POST when JS is off. +*/ +document.addEventListener('submit', function (e) { + var form = e.target.closest('.os-message-dismiss-form'); + if (!form) return; + e.preventDefault(); + var banner = form.closest('.announcement-banner'); + if (banner) { + banner.style.transition = 'opacity 0.2s'; + banner.style.opacity = '0'; + setTimeout(function () { banner.remove(); }, 200); + } + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }); +}); + /* ---- Collapse shim ---- Handles [data-toggle="collapse"] by toggling .in on the target element. CSS in tailwind.css: .collapse { display:none } .collapse.in { display:block } diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 1929f8370f8..b3ee380b913 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -503,6 +503,15 @@ {% for banner in additional_banners %}