Skip to content

Commit 1eff475

Browse files
authored
Remove XSS potential using format_html helpers (#2950)
1 parent 367f6bb commit 1eff475

File tree

7 files changed

+94
-78
lines changed

7 files changed

+94
-78
lines changed

apps/boxes/templatetags/boxes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
def box(label):
1616
"""Render the content of a Box identified by its label slug."""
1717
try:
18-
return mark_safe(Box.objects.only("content").get(label=label).content.rendered)
18+
return mark_safe(Box.objects.only("content").get(label=label).content.rendered) # noqa: S308
1919
except Box.DoesNotExist:
2020
log.warning("WARNING: box not found: label=%s", label)
2121
return ""

apps/companies/templatetags/companies.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from django import template
44
from django.template.defaultfilters import stringfilter
5-
from django.utils.html import format_html
5+
from django.utils.html import format_html_join, mark_safe
66

77
register = template.Library()
88

99

10-
@register.filter(is_safe=True)
10+
@register.filter()
1111
@stringfilter
1212
def render_email(value):
1313
"""Render an email address with obfuscated dots and at-sign using spans."""
@@ -16,8 +16,8 @@ def render_email(value):
1616
mailbox_tokens = mailbox.split(".")
1717
domain_tokens = domain.split(".")
1818

19-
mailbox = "<span>.</span>".join(mailbox_tokens)
20-
domain = "<span>.</span>".join(domain_tokens)
19+
mailbox = format_html_join(mark_safe("<span>.</span>"), "{}", [(token,) for token in mailbox_tokens])
20+
domain = format_html_join(mark_safe("<span>.</span>"), "{}", [(token,) for token in domain_tokens])
2121

22-
return format_html(f"{mailbox}<span>@</span>{domain}")
22+
return mailbox + mark_safe("<span>@</span>") + domain
2323
return None

apps/downloads/templatetags/download_tags.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import requests
77
from django import template
88
from django.core.cache import cache
9-
from django.utils.html import format_html
10-
from django.utils.safestring import mark_safe
9+
from django.utils.html import format_html, format_html_join, mark_safe
1110

1211
from apps.downloads.models import Release
1312

@@ -111,11 +110,11 @@ def wbr_wrap(value: str | None) -> str:
111110

112111
# Split into two halves, each half has internal <wbr> breaks
113112
midpoint = len(chunks) // 2
114-
first_half = "<wbr>".join(chunks[:midpoint])
115-
second_half = "<wbr>".join(chunks[midpoint:])
113+
first_half = format_html_join(mark_safe("<wbr>"), "{}", chunks[:midpoint])
114+
second_half = format_html_join(mark_safe("<wbr>"), "{}", chunks[midpoint:])
116115

117-
return mark_safe(
118-
f'<span class="checksum-half">{first_half}</span><wbr><span class="checksum-half">{second_half}</span>'
116+
return format_html(
117+
'<span class="checksum-half">{}</span><wbr><span class="checksum-half">{}</span>', first_half, second_half
119118
)
120119

121120

apps/sponsors/admin.py

Lines changed: 78 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Django admin configuration for the sponsors app."""
22

33
import contextlib
4+
from textwrap import dedent
45

56
from django.contrib import admin
67
from django.contrib.contenttypes.admin import GenericTabularInline
@@ -12,7 +13,7 @@
1213
from django.template import Context, Template
1314
from django.urls import path, reverse
1415
from django.utils.functional import cached_property
15-
from django.utils.html import mark_safe
16+
from django.utils.html import format_html, format_html_join, mark_safe
1617
from import_export import resources
1718
from import_export.admin import ImportExportActionModelAdmin
1819
from import_export.fields import Field
@@ -296,12 +297,12 @@ def get_benefit_split(self, obj: SponsorshipPackage) -> str:
296297
for i, (name, pct) in enumerate(split):
297298
pct_str = f"{pct:.0f}%"
298299
widths.append(pct_str)
299-
spans.append(f"<span title='{name}' style='background-color:{colors[i]}'>{pct_str}</span>")
300+
spans.append((name, colors[i], pct_str))
300301
# define a style that will show our span elements like a single horizontal stacked bar chart
301302
style = f"color:#fff;text-align:center;cursor:pointer;display:grid;grid-template-columns:{' '.join(widths)}"
303+
split_bar = format_html_join("", "<span title='{}' style='background-color:{}'>{}</span>", spans)
302304
# wrap it all up and put a bow on it
303-
html = f"<div style='{style}'>{''.join(spans)}</div>"
304-
return mark_safe(html)
305+
return format_html("<div style='{}'>{}</div>", style, split_bar)
305306

306307

307308
class SponsorContactInline(admin.TabularInline):
@@ -325,7 +326,7 @@ class SponsorshipsInline(admin.TabularInline):
325326
def link(self, obj):
326327
"""Return a link to the sponsorship change page."""
327328
url = reverse("admin:sponsors_sponsorship_change", args=[obj.id])
328-
return mark_safe(f"<a href={url}>{obj.id}</a>")
329+
return format_html("<a href='{}'>{}</a>", url, obj.id)
329330

330331

331332
@admin.register(Sponsor)
@@ -652,27 +653,25 @@ def get_readonly_fields(self, request, obj):
652653
def sponsor_link(self, obj):
653654
"""Return an HTML link to the sponsor's admin change page."""
654655
url = reverse("admin:sponsors_sponsor_change", args=[obj.sponsor.id])
655-
return mark_safe(f"<a href={url}>{obj.sponsor.name}</a>")
656+
return format_html("<a href='{}'>{}</a>", url, obj.sponsor.name)
656657

657658
@admin.display(description="Estimated cost")
658659
def get_estimated_cost(self, obj):
659660
"""Return the estimated cost HTML for customized sponsorships."""
660-
cost = None
661661
html = "This sponsorship has not customizations so there's no estimated cost"
662662
if obj.for_modified_package:
663663
msg = "This sponsorship has customizations and this cost is a sum of all benefit's internal values from when this sponsorship was created"
664664
cost = intcomma(obj.estimated_cost)
665-
html = f"{cost} USD <br/><b>Important: </b> {msg}"
666-
return mark_safe(html)
665+
html = format_html("{} USD <br/><b>Important: </b> {}", cost, msg)
666+
return html
667667

668668
@admin.display(description="Contract")
669669
def get_contract(self, obj):
670670
"""Return an HTML link to the contract or a placeholder."""
671671
if not obj.contract:
672672
return "---"
673673
url = reverse("admin:sponsors_contract_change", args=[obj.contract.pk])
674-
html = f"<a href='{url}' target='_blank'>{obj.contract}</a>"
675-
return mark_safe(html)
674+
return format_html("<a href='{}' target='_blank'>{}</a>", url, obj.contract)
676675

677676
def get_urls(self):
678677
"""Register custom admin URLs for sponsorship workflow actions."""
@@ -741,19 +740,20 @@ def get_sponsor_web_logo(self, obj):
741740
template = Template(html)
742741
context = Context({"sponsor": obj.sponsor})
743742
html = template.render(context)
744-
return mark_safe(html)
743+
return mark_safe(html) # noqa: S308
745744

746745
@admin.display(description="Print Logo")
747746
def get_sponsor_print_logo(self, obj):
748747
"""Render and return the sponsor's print logo as a thumbnail image."""
749748
img = obj.sponsor.print_logo
750-
html = ""
749+
html = "---"
751750
if img:
752-
html = "{% load thumbnail %}{% thumbnail img '150x150' format='PNG' quality=100 as im %}<img src='{{ im.url}}'/>{% endthumbnail %}"
753-
template = Template(html)
751+
template = Template(
752+
"{% load thumbnail %}{% thumbnail img '150x150' format='PNG' quality=100 as im %}<img src='{{ im.url}}'/>{% endthumbnail %}"
753+
)
754754
context = Context({"img": img})
755-
html = template.render(context)
756-
return mark_safe(html) if html else "---"
755+
html = mark_safe(template.render(context)) # noqa: S308
756+
return html
757757

758758
@admin.display(description="Primary Phone")
759759
def get_sponsor_primary_phone(self, obj):
@@ -764,18 +764,24 @@ def get_sponsor_primary_phone(self, obj):
764764
def get_sponsor_mailing_address(self, obj):
765765
"""Return the sponsor's formatted mailing address as HTML."""
766766
sponsor = obj.sponsor
767-
city_row = f"{sponsor.city} - {sponsor.get_country_display()} ({sponsor.country})"
768767
if sponsor.state:
769-
city_row = f"{sponsor.city} - {sponsor.state} - {sponsor.get_country_display()} ({sponsor.country})"
768+
city_row_html = format_html(
769+
"<p>{} - {} - {} ({})</p>", sponsor.city, sponsor.state, sponsor.get_country_display(), sponsor.country
770+
)
771+
else:
772+
city_row_html = format_html(
773+
"<p>{} - {} ({})</p>", sponsor.city, sponsor.get_country_display(), sponsor.country
774+
)
770775

771-
mail_row = sponsor.mailing_address_line_1
772776
if sponsor.mailing_address_line_2:
773-
mail_row += f" - {sponsor.mailing_address_line_2}"
777+
mail_row_html = format_html(
778+
"<p>{} - {}</p>", sponsor.mailing_address_line_1, sponsor.mailing_address_line_2
779+
)
780+
else:
781+
mail_row_html = format_html("<p>{}</p>", sponsor.mailing_address_line_1)
774782

775-
html = f"<p>{city_row}</p>"
776-
html += f"<p>{mail_row}</p>"
777-
html += f"<p>{sponsor.postal_code}</p>"
778-
return mark_safe(html)
783+
postal_code_row = format_html("<p>{}</p>", sponsor.postal_code)
784+
return city_row_html + mail_row_html + postal_code_row
779785

780786
@admin.display(description="Contacts")
781787
def get_sponsor_contacts(self, obj):
@@ -785,14 +791,14 @@ def get_sponsor_contacts(self, obj):
785791
primary = [c for c in contacts if c.primary]
786792
not_primary = [c for c in contacts if not c.primary]
787793
if primary:
788-
html = "<b>Primary contacts</b><ul>"
789-
html += "".join([f"<li>{c.name}: {c.email} / {c.phone}</li>" for c in primary])
790-
html += "</ul>"
794+
html = mark_safe("<b>Primary contacts</b><ul>")
795+
html += format_html_join("", "<li>{}: {} / {}</li>", [(c.name, c.email, c.phone) for c in primary])
796+
html += mark_safe("</ul>")
791797
if not_primary:
792-
html += "<b>Other contacts</b><ul>"
793-
html += "".join([f"<li>{c.name}: {c.email} / {c.phone}</li>" for c in not_primary])
794-
html += "</ul>"
795-
return mark_safe(html)
798+
html += mark_safe("<b>Other contacts</b><ul>")
799+
html += format_html_join("", "<li>{}: {} / {}</li>", [(c.name, c.email, c.phone) for c in not_primary])
800+
html += mark_safe("</ul>")
801+
return html
796802

797803
@admin.display(description="Added by User")
798804
def get_custom_benefits_added_by_user(self, obj):
@@ -801,8 +807,7 @@ def get_custom_benefits_added_by_user(self, obj):
801807
if not benefits:
802808
return "---"
803809

804-
html = "".join([f"<p>{b}</p>" for b in benefits])
805-
return mark_safe(html)
810+
return format_html_join("", "<p>{}</p>", benefits)
806811

807812
@admin.display(description="Removed by User")
808813
def get_custom_benefits_removed_by_user(self, obj):
@@ -811,8 +816,7 @@ def get_custom_benefits_removed_by_user(self, obj):
811816
if not benefits:
812817
return "---"
813818

814-
html = "".join([f"<p>{b}</p>" for b in benefits])
815-
return mark_safe(html)
819+
return format_html_join("", "<p>{}</p>", benefits)
816820

817821
def rollback_to_editing_view(self, request, pk):
818822
"""Delegate to the rollback_to_editing admin view."""
@@ -878,18 +882,25 @@ def links(self, obj):
878882
benefits_url = reverse("admin:sponsors_sponsorshipbenefit_changelist")
879883
preview_label = "View sponsorship application"
880884
year = obj.year
881-
html = "<ul>"
882885
preview_querystring = f"config_year={year}"
883886
preview_url = f"{application_url}?{preview_querystring}"
884887
filter_querystring = f"year={year}"
885888
year_benefits_url = f"{benefits_url}?{filter_querystring}"
886889
year_packages_url = f"{benefits_url}?{filter_querystring}"
887890

888-
html += f"<li><a target='_blank' href='{year_packages_url}'>List packages</a>"
889-
html += f"<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>"
890-
html += f"<li><a target='_blank' href='{preview_url}'>{preview_label}</a>"
891-
html += "</ul>"
892-
return mark_safe(html)
891+
return format_html(
892+
dedent("""
893+
<ul>
894+
<li><a target='_blank' href='{year_packages_url}'>List packages</a>
895+
<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>
896+
<li><a target='_blank' href='{preview_url}'>{preview_label}</a>
897+
</ul>
898+
"""),
899+
year_packages_url=year_packages_url,
900+
year_benefits_url=year_benefits_url,
901+
preview_url=preview_url,
902+
preview_label=preview_label,
903+
)
893904

894905
@admin.display(description="Other configured years")
895906
def other_years(self, obj):
@@ -904,22 +915,32 @@ def other_years(self, obj):
904915
application_url = reverse("select_sponsorship_application_benefits")
905916
benefits_url = reverse("admin:sponsors_sponsorshipbenefit_changelist")
906917
preview_label = "View sponsorship application form for this year"
907-
html = "<ul>"
918+
html = mark_safe("<ul>")
908919
for year in configured_years:
909920
preview_querystring = f"config_year={year}"
910921
preview_url = f"{application_url}?{preview_querystring}"
911922
filter_querystring = f"year={year}"
912923
year_benefits_url = f"{benefits_url}?{filter_querystring}"
913924
year_packages_url = f"{benefits_url}?{filter_querystring}"
914925

915-
html += f"<li><b>{year}</b>:"
916-
html += "<ul>"
917-
html += f"<li><a target='_blank' href='{year_packages_url}'>List packages</a>"
918-
html += f"<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>"
919-
html += f"<li><a target='_blank' href='{preview_url}'>{preview_label}</a>"
920-
html += "</ul></li>"
921-
html += "</ul>"
922-
return mark_safe(html)
926+
html += format_html(
927+
dedent("""
928+
<li><b>{year}</b>:"
929+
<ul>
930+
<li><a target='_blank' href='{year_packages_url}'>List packages</a>
931+
<li><a target='_blank' href='{year_benefits_url}'>List benefits</a>
932+
<li><a target='_blank' href='{preview_url}'>{preview_label}</a>
933+
</ul>
934+
</li>
935+
"""),
936+
year=year,
937+
year_packages_url=year_packages_url,
938+
year_benefits_url=year_benefits_url,
939+
preview_url=preview_url,
940+
preview_label=preview_label,
941+
)
942+
html += mark_safe("</ul>")
943+
return html
923944

924945
def clone_application_config(self, request):
925946
"""Delegate to the clone_application_config admin view."""
@@ -1042,17 +1063,16 @@ def document_link(self, obj):
10421063
msg = "Download Signed Contract"
10431064

10441065
if url and msg:
1045-
html = f'<a href="{url}" target="_blank">{msg}</a>'
1046-
return mark_safe(html)
1066+
html = format_html('<a href="{}" target="_blank">{}</a>', url, msg)
1067+
return html
10471068

10481069
@admin.display(description="Sponsorship")
10491070
def get_sponsorship_url(self, obj):
10501071
"""Return an HTML link to the related sponsorship's admin page."""
10511072
if not obj.sponsorship:
10521073
return "---"
10531074
url = reverse("admin:sponsors_sponsorship_change", args=[obj.sponsorship.pk])
1054-
html = f"<a href='{url}' target='_blank'>{obj.sponsorship}</a>"
1055-
return mark_safe(html)
1075+
return format_html("<a href='{}' target='_blank'>{}</a>", url, obj.sponsorship)
10561076

10571077
def get_urls(self):
10581078
"""Register custom admin URLs for contract workflow actions."""
@@ -1257,8 +1277,8 @@ def get_value(self, obj):
12571277
"""Return the asset value, linking to the file URL if applicable."""
12581278
html = obj.value
12591279
if obj.value and getattr(obj.value, "url", None):
1260-
html = f"<a href='{obj.value.url}' target='_blank'>{obj.value}</a>"
1261-
return mark_safe(html)
1280+
html = format_html("<a href='{}' target='_blank'>{}</a>", (obj.value.url, obj.value))
1281+
return html
12621282

12631283
@admin.display(description="Associated with")
12641284
def get_related_object(self, obj):
@@ -1276,8 +1296,7 @@ def get_related_object(self, obj):
12761296
if not content_object: # safety belt
12771297
return obj.content_object
12781298

1279-
html = f"<a href='{content_object.admin_url}' target='_blank'>{content_object}</a>"
1280-
return mark_safe(html)
1299+
return format_html("<a href='{}' target='_blank'>{}</a>", content_object.admin_url, content_object)
12811300

12821301
@admin.action(description="Export selected")
12831302
def export_assets_as_zipfile(self, request, queryset):

apps/sponsors/forms.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.db.models import Q
1111
from django.utils import timezone
1212
from django.utils.functional import cached_property
13-
from django.utils.safestring import mark_safe
13+
from django.utils.html import format_html
1414
from django.utils.text import slugify
1515
from django.utils.translation import gettext_lazy as _
1616
from django_countries.fields import CountryField
@@ -751,11 +751,11 @@ def __init__(self, *args, **kwargs):
751751
field = required_asset.as_form_field(required=required, initial=value)
752752

753753
if required_asset.due_date and not bool(value):
754-
field.label = mark_safe(
755-
f"<big><b>{field.label}</b></big><br><b>(Required by {required_asset.due_date})</b>"
754+
field.label = format_html(
755+
"<big><b>{}</b></big><br><b>(Required by {})</b>", field.label, required_asset.due_date
756756
)
757757
if bool(value):
758-
field.label = mark_safe(f"<big><b>{field.label}</b></big><br><small>(Fulfilled, thank you!)</small>")
758+
field.label = format_html("<big><b>{}</b></big><br><small>(Fulfilled, thank you!)</small>", field.label)
759759

760760
fields[f_name] = field
761761

apps/successstories/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ def get_list_display(self, request):
3535
@admin.display(description="View on site")
3636
def show_link(self, obj):
3737
"""Return a clickable link icon to the story's public page."""
38-
return format_html(f'<a href="{obj.get_absolute_url()}">\U0001f517</a>')
38+
return format_html('<a href="{}">\U0001f517</a>', obj.get_absolute_url())

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,6 @@ ignore = [
141141
# Boolean args are idiomatic in Django models, forms, and views
142142
"FBT001", # boolean-positional-arg-in-function-definition
143143
"FBT002", # boolean-default-value-positional-argument
144-
# mark_safe is required Django pattern for admin display
145-
"S308", # suspicious-mark-safe-usage
146144
# Circular imports are resolved with local imports in Django
147145
"PLC0415", # import-outside-top-level
148146
# TODO comment formatting is not worth enforcing

0 commit comments

Comments
 (0)