Django XSS Protection Disabled

ID

python.django_xss_protection_disabled

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Misconfiguration

Language

Python

Tags

ASVS50:v1.3.1, CWE:80, NIST.SP.800-53, OWASP:2025:A02, OWASP:2025:A05, PCI-DSS:6.5.7, django

Description

Improper configurations or misuse of Django’s templating system can disable XSS protection, exposing applications to cross-site scripting attacks.

Rationale

Cross-Site Scripting (XSS) is a vulnerability that occurs when an application includes untrusted data in a web page without proper validation or escaping.

Django’s templating system is designed with built-in protections to prevent cross-site scripting (XSS) vulnerabilities. These protections automatically escape HTML input, ensuring that user-supplied content is not executed as code on the client side.

However, developers may inadvertently disable these protections by marking content as "safe" or bypassing the template engine, increasing the risk of XSS attacks. Some common misconfigurations and practices can lead to such vulnerabilities:

Global Autoescape disabled Setting

Global Autoescape is a feature in Django’s template system that automatically escapes variables when rendering templates. This crucial security measure helps prevent Cross-Site Scripting (XSS) attacks by ensuring that any HTML, JavaScript, or CSS code in user inputs is not executed by the browser.

# Example of disabling autoescape in template settings
TEMPLATES = [
  {
    'BACKEND': 'django.template.backends.django.DjangoTemplates',
    'OPTIONS': {'autoescape': False},  # FLAW
  },
]

Non Global Autoescape Disabled Settings

These issues are reported with a lower severity degree.

mark_safe

mark_safe is a utility function in Django that marks a string as safe for inclusion in HTML output without escaping. It’s part of Django’s template system and is used to inject existing HTML or JavaScript code into templates.

If user input is marked safe without proper sanitization, it can lead to cross-site scripting attacks by injecting malicious scripts.

from django.utils.safestring import mark_safe

html_content = "<strong>Important:</strong> Read this!"
safe_content = mark_safe(html_content) # FLAW

SafeString

SafeString is a class used internally by Django to indicate that a string is safe for HTML output, meaning it should not be auto-escaped by the template engine. It’s how Django keeps track of which strings have been marked as safe (When you use mark_safe, it returns a SafeString instance).

If user input is marked safe without proper sanitization, it can lead to cross-site scripting attacks by injecting malicious scripts.

from django.utils.safestring import SafeString

html_content = "<strong>Important:</strong> Read this!"
safe_content = SafeString(html_content) # FLAW

What is not reported

mark_safe and SafeString over text the author wrote suppress no escaping decision, so they are not reported. That covers a literal, a translated literal, and any concatenation or format of those:

mark_safe('<code>100,200,300</code>')                              # not reported
mark_safe(_('Assigned tagged VLAN IDs, encased with double quotes')) # not reported
mark_safe(_('Example:') + ' <code>"100,200,300"</code>')            # not reported

It also covers an expression that escapes every value it interpolates. mark_safe is how Django returns pre-escaped HTML, so marking an f-string or a format whose dynamic parts all pass through escape, conditional_escape, escapejs or format_html suppresses no escaping decision either:

mark_safe(f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>')   # not reported
mark_safe(_('Must match: <code>{regex}</code>').format(regex=escape(pattern)))      # not reported

Where escaping is not what makes it safe

The exemption above is refused wherever HTML escaping is not the protection that matters. escape() turns < into <; it leaves javascript:alert(1) untouched, so an escaped value that opens an href, src, action or style, or that lands inside a <script> element or an event-handler attribute, is still reported:

mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')   # reported: value can be a javascript: URI

In URL position only an application-generated URL passes — a reverse()`d route, `resolve_url, static, or Django’s get_absolute_url convention:

mark_safe(' / '.join(f'<a href="{n.get_absolute_url()}">{escape(n)}</a>' for n in nodes))  # not reported

Values that carry no text of anyone’s choosing

A model’s own field read through self in one of its methods passes when the model, or a model base it inherits, declares it a number, a boolean, a date, a duration or a UUID. A field with choices, a SlugField, or one an anchored RegexValidator (or validate_slug) restricts passes where its characters cannot be misused — no : or & in a URL, no <, > or quote in markup — as long as no ORM write of the project (objects.create(f=…​), .update(f=…​), get_or_create, update_or_create, a model built in bulk_create) hands that field a value that is not a literal or a form’s cleaned_data. The url of a FileField or ImageField passes, unless a storage class of the project overrides url().

A read of the settings or of the configuration administrators edit in the database is the operators' text and passes too, as far as the rule’s properties trust it. Configuration is told by where the module imports it from, never by a name alone: the settings module (from django.conf import settings); the configuration libraries administrators edit — config from django-constance, global_preferences_registry.manager() from django-dynamic-preferences, get_solo() on a django-solo SingletonModel, for_request()/for_site()/load() on a Wagtail BaseSiteSetting or BaseGenericSetting; a function of the project every return of which is one of those reads; and the accessors the trusted_config_accessors property lists. A project object or function that merely has a configuration-like name (config, get_config()) is not configuration. A value fetched from somewhere else — a feed, a remote catalogue — is not configuration either:

class Product(models.Model):
    code = models.CharField(validators=[RegexValidator(r'^[A-Z0-9-]+$')])

    def code_tag(self):
        return mark_safe(f'<code>{self.code}</code>')          # not reported

mark_safe(settings.BANNER_HTML)                                 # not reported (trust_settings)
mark_safe(config.BANNER_TOP)                                    # not reported (constance, trust_admin_config)
mark_safe(SiteConfiguration.get_solo().footer)                  # not reported (django-solo, trust_admin_config)
mark_safe(get_config().BANNER_TOP)                              # reported, unless trusted_config_accessors lists get_config
mark_safe(requests.get(CATALOGUE).json()['summary'])            # reported

What is still reported

A module-level constant is resolved: a name the file assigns exactly once outside every function and class. A name assigned twice, or imported rather than assigned, resolves to nothing and the call is reported.

A local name is resolved when every binding of it in the function holds safe HTML, wherever the binding sits: a plain assignment, a +=, and an .append() on a list the function creates. A parameter passes only when the statement right before the use rebinds it to a safe value:

PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
mark_safe(PLACEHOLDER_HTML)   # not reported: assigned once, at module level

badges = []
for label, color in parts:
    badges.append(f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>')
mark_safe(' '.join(badges))   # not reported: every element appended is escaped

value = conditional_escape(value)
mark_safe(f'<pre>{value}</pre>')   # not reported: escaped right before

Any binding that cannot be read that way leaves the name unresolved, and the call is reported: a parameter, a value appended raw on any branch, an .extend(), an augmented operator other than +=, a subscript assignment or tuple unpacking, a loop, with or except target, a list handed to another call, and a global or nonlocal name.

mark_safe(html)               # reported: html is a parameter

The same treatment applies to Markup in python.flask_xss_protection_disabled.

Custom Filter

In Django, custom template filters can be created to manipulate data in templates. By default, Django auto-escapes filter output to prevent XSS vulnerabilities. However, you can mark a custom filter’s output as safe by setting is_safe=True.

If user input is marked safe without proper sanitization, it can lead to cross-site scripting attacks by injecting malicious scripts.

from django import template

register = template.Library()

@register.filter(is_safe=True) # FLAW
def highlight(value):
    return f"<strong>{value}</strong>"

HTML Magic

The html magic method in Django is used to define how an object should be rendered as HTML. If this method is defined, Django’s template engine will use its return value during HTML rendering, and it will not be escaped.

If user input is marked safe without proper sanitization, it can lead to cross-site scripting attacks by injecting malicious scripts.

class SafeContent:
    def __init__(self, content):
        self.content = content

    def __html__(self): # FLAW
        return f"<strong>{self.content}</strong>"

obj = SafeContent("Hello, World!")
html_output = obj.__html__()

html_safe

The html_safe decorator in Django is used to automatically add an html method to a class. This method indicates that the class’s string representation should be treated as safe HTML, exempting it from auto-escaping in templates.

If user input is marked safe without proper sanitization, it can lead to cross-site scripting attacks by injecting malicious scripts.

from django.utils.decorators import html_safe

@html_safe # FLAW
class HtmlContent(str):
    pass

content = HtmlContent("<em>Safe HTML Content</em>")

Configuration

Property Default Meaning

trust_settings

true

A read of the Django settings module is safe in a URL and in markup. Set to false to report it like any other value.

trust_admin_config

true

A read of the configuration administrators edit in the database (django-constance, the global preferences of django-dynamic-preferences, a django-solo singleton, Wagtail settings) is safe in a URL and in markup. Set to false to report it like any other value.

trusted_config_accessors

[]

Fully qualified names of further functions or objects that return configuration, such as myapp.config.get_config. A call to a listed function, or a read of a listed object, that the import of the module resolves to that name is safe in a URL and in markup, whatever the trust properties say. A bare name is ignored.

Remediation

Carefully review every issue addressed by this detector to ensure that the unescaped HTML code is safe.

To effectively address XSS vulnerabilities when using Django enable all the autoescape settings.

References

© 2026 Xygeni. All rights reserved