From 507dfd4841ac9ef6e74b29db74cef5fada7446bf Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 25 Aug 2026 21:13:56 +0200 Subject: [PATCH 1/9] devel: Use the formatting options of format_html The string interpolation happens before `format_html` so theoretically this allows XSS. --- devel/reports.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/devel/reports.py b/devel/reports.py index ca8e9369..26166f14 100644 --- a/devel/reports.py +++ b/devel/reports.py @@ -21,8 +21,7 @@ def __init__(self, href, title, desc): self.desc = desc def __str__(self): - link = '%s' - return format_html(link % (self.href, self.title, self.desc)) + return format_html('{}', self.href, self.title, self.desc) def linkify_non_reproducible_packages(statuses): From d5f517b87891aba2738f1e9603a20b7e47ba7f24 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 25 Aug 2026 21:30:34 +0200 Subject: [PATCH 2/9] sitestatic: Prevent XSS from package data This is highly theoretical and would require the repository database to compromised but this it is safer to construct HTML elements this way. --- sitestatic/homepage.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sitestatic/homepage.js b/sitestatic/homepage.js index 35dfb0ea..8a6bb7b9 100644 --- a/sitestatic/homepage.js +++ b/sitestatic/homepage.js @@ -53,8 +53,21 @@ for (let i = 0; i < pkgs.length; i++) { const item = document.createElement("li"); - const text = pkgs[i].replace(letter, `${letter}`); - item.innerHTML = `${text}`; + const anchor = document.createElement("a"); + anchor.href = "#"; + + const matchIndex = pkgs[i].indexOf(letter); + if (matchIndex !== -1) { + anchor.appendChild(document.createTextNode(pkgs[i].slice(0, matchIndex))); + const bold = document.createElement("b"); + bold.textContent = pkgs[i].slice(matchIndex, matchIndex + letter.length); + anchor.appendChild(bold); + anchor.appendChild(document.createTextNode(pkgs[i].slice(matchIndex + letter.length))); + } else { + anchor.textContent = pkgs[i]; + } + + item.appendChild(anchor); item.setAttribute('data-value', pkgs[i]); fragment.appendChild(item); } From 2fcf019a1a98608bbb99f5e75e18b2b8594eba29 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 25 Aug 2026 21:36:02 +0200 Subject: [PATCH 3/9] sitestatic: Use modern ES6 for loop constructs --- sitestatic/homepage.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sitestatic/homepage.js b/sitestatic/homepage.js index 8a6bb7b9..d6e17828 100644 --- a/sitestatic/homepage.js +++ b/sitestatic/homepage.js @@ -51,24 +51,24 @@ ul.style.display = "block"; const fragment = document.createDocumentFragment(); - for (let i = 0; i < pkgs.length; i++) { + for (const pkgname of pkgs) { const item = document.createElement("li"); const anchor = document.createElement("a"); anchor.href = "#"; - const matchIndex = pkgs[i].indexOf(letter); + const matchIndex = pkgname.indexOf(letter); if (matchIndex !== -1) { - anchor.appendChild(document.createTextNode(pkgs[i].slice(0, matchIndex))); + anchor.appendChild(document.createTextNode(pkgname.slice(0, matchIndex))); const bold = document.createElement("b"); - bold.textContent = pkgs[i].slice(matchIndex, matchIndex + letter.length); + bold.textContent = pkgname.slice(matchIndex, matchIndex + letter.length); anchor.appendChild(bold); - anchor.appendChild(document.createTextNode(pkgs[i].slice(matchIndex + letter.length))); + anchor.appendChild(document.createTextNode(pkgname.slice(matchIndex + letter.length))); } else { - anchor.textContent = pkgs[i]; + anchor.textContent = pkgname; } item.appendChild(anchor); - item.setAttribute('data-value', pkgs[i]); + item.setAttribute('data-value', pkgname); fragment.appendChild(item); } From 764aa3e8044eba0d87b7e5304eabaecaa3b73a7b Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 25 Aug 2026 21:43:19 +0200 Subject: [PATCH 4/9] main: Use format_hmtl named placeholder for formatting This removes the need for `mark_safe`. --- main/templatetags/cdn.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/main/templatetags/cdn.py b/main/templatetags/cdn.py index c87d78b4..60cae12d 100644 --- a/main/templatetags/cdn.py +++ b/main/templatetags/cdn.py @@ -1,7 +1,6 @@ from django import template from django.contrib.staticfiles.storage import staticfiles_storage from django.utils.html import format_html -from django.utils.safestring import mark_safe register = template.Library() @@ -11,7 +10,7 @@ def jquery() -> str: version = '3.6.0' filename = f'jquery-{version}.min.js' link = staticfiles_storage.url(filename) - return mark_safe(f'') + return format_html('', link=link) @register.simple_tag From 9f1aebcc3f53425de00a6511d604cbb9e069ac0b Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Tue, 25 Aug 2026 21:51:09 +0200 Subject: [PATCH 5/9] news: Require authentication for news preview Posting new is an authenticated operation and so is requesting a preview. --- news/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/news/views.py b/news/views.py index a8009427..b961afcc 100644 --- a/news/views.py +++ b/news/views.py @@ -1,5 +1,6 @@ from django import forms from django.conf import settings +from django.contrib.auth.decorators import login_required from django.core.mail import EmailMessage from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect @@ -82,6 +83,7 @@ def view_redirect(request, object_id): return redirect(newsitem, permanent=True) +@login_required @require_POST def preview(request): data = request.POST.get('data', '') From 3a88783a7bfdb73334bdb552bd22771f4ab005e7 Mon Sep 17 00:00:00 2001 From: Jelle van der Waa Date: Wed, 26 Aug 2026 20:15:33 +0200 Subject: [PATCH 6/9] devel: Use format_html named placeholder for formatting --- devel/reports.py | 3 ++- devel/tests/test_reports.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/devel/reports.py b/devel/reports.py index 26166f14..8d67eb33 100644 --- a/devel/reports.py +++ b/devel/reports.py @@ -21,7 +21,8 @@ def __init__(self, href, title, desc): self.desc = desc def __str__(self): - return format_html('{}', self.href, self.title, self.desc) + return format_html('{desc}', + href=self.href, title=self.title, desc=self.desc) def linkify_non_reproducible_packages(statuses): diff --git a/devel/tests/test_reports.py b/devel/tests/test_reports.py index 96b85a74..62729deb 100644 --- a/devel/tests/test_reports.py +++ b/devel/tests/test_reports.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from django.test import TransactionTestCase +from devel.reports import Linkify from packages.models import PackageRelation @@ -98,3 +99,12 @@ def test_report_pkgbases_filtered_by_maintainer(self): f'/devel/reports/old/{self.user.username}/pkgbases/') self.assertEqual(response.status_code, 200) self.assertEqual(response.content.decode().strip(), 'linux') + + +def test_linkify_escapes_html(): + link = Linkify(href='">', title='', desc='xss') + result = str(link) + assert '