CVE-2018-25045: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Django REST framework (aka django-rest-framework) before 3.9.1 allows XSS because the default DRF Browsable API view templates disable autoescaping.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-07-23T02:15Z
Repository: https://github.com/encode/django-rest-framework
Language: Python
Files changed: rest_framework/templates/rest_framework/base.html, rest_framework/templatetags/rest_framework.py, tests/test_templatetags.py
Commit date: 2019-01-16 12:36:25 +0000
Commit message: Fix XSS caused by disabled autoescaping in the default DRF Browsable API view templates (#6330)

* Add test that verifies that HTML is correctly escaped in Browsable API views

* Fix `urlize_quoted_links` tag to avoid double escaping in autoescape mode

* Fix XSS in default DRF Browsable API template by re-enabling autoescape

Diff: diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html
index 26395e1f..688fd231 100644
--- a/rest_framework/templates/rest_framework/base.html
+++ b/rest_framework/templates/rest_framework/base.html
@@ -169,14 +169,14 @@
               <div class="request-info" style="clear: both" aria-label="{% trans "request info" %}">
                 <pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre>
               </div>
 
               <div class="response-info" aria-label="{% trans "response info" %}">
-                <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% autoescape off %}{% for key, val in response_headers|items %}
+                <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% for key, val in response_headers|items %}
 <b>{{ key }}:</b> <span class="lit">{{ val|break_long_headers|urlize_quoted_links }}</span>{% endfor %}
 
-</span>{{ content|urlize_quoted_links }}</pre>{% endautoescape %}
+</span>{{ content|urlize_quoted_links }}</pre>
               </div>
             </div>
 
             {% if display_edit_forms %}
               {% if post_form or raw_data_post_form %}
diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py
index 39233897..f48675d5 100644
--- a/rest_framework/templatetags/rest_framework.py
+++ b/rest_framework/templatetags/rest_framework.py
@@ -334,10 +334,16 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru
     """
     def trim_url(x, limit=trim_url_limit):
         return limit is not None and (len(x) > limit and ('%s...' % x[:max(0, limit - 3)])) or x
 
     safe_input = isinstance(text, SafeData)
+
+    # Unfortunately, Django built-in cannot be used here, because escaping
+    # is to be performed on words, which have been forcibly coerced to text
+    def conditional_escape(text):
+        return escape(text) if autoescape and not safe_input else text
+
     words = word_split_re.split(force_text(text))
     for i, word in enumerate(words):
         if '.' in word or '@' in word or ':' in word:
             # Deal with punctuation.
             lead, middle, trail = '', word, ''
@@ -374,25 +380,19 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru
                 nofollow_attr = ''
 
             # Make link.
             if url:
                 trimmed = trim_url(middle)
-                if autoescape and not safe_input:
-                    lead, trail = escape(lead), escape(trail)
-                    url, trimmed = escape(url), escape(trimmed)
+                lead, trail = conditional_escape(lead), conditional_escape(trail)
+                url, trimmed = conditional_escape(url), conditional_escape(trimmed)
                 middle = '<a href="%s"%s>%s</a>' % (url, nofollow_attr, trimmed)
-                words[i] = mark_safe('%s%s%s' % (lead, middle, trail))
+                words[i] = '%s%s%s' % (lead, middle, trail)
             else:
-                if safe_input:
-                    words[i] = mark_safe(word)
-                elif autoescape:
-                    words[i] = escape(word)
-        elif safe_input:
-            words[i] = mark_safe(word)
-        elif autoescape:
-            words[i] = escape(word)
-    return ''.join(words)
+                words[i] = conditional_escape(word)
+        else:
+            words[i] = conditional_escape(word)
+    return mark_safe(''.join(words))
 
 
 @register.filter
 def break_long_headers(header):
     """
diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py
index 5d4f6a4e..45bfd4ae 100644
--- a/tests/test_templatetags.py
+++ b/tests/test_templatetags.py
@@ -303,19 +303,28 @@ class URLizerTests(TestCase):
             '&quot;url&quot;: &quot;<a href="http://api/users/1/">http://api/users/1/</a>&quot;, '
         data['"foo_set": [\n    "http://api/foos/1/"\n], '] = \
             '&quot;foo_set&quot;: [\n    &quot;<a href="http://api/foos/1/">http://api/foos/1/</a>&quot;\n], '
         self._urlize_dict_check(data)
 
+    def test_template_render_with_autoescape(self):
+        """
+        Test that HTML is correctly escaped in Browsable API views.
+        """
+        template = Template("{% load rest_framework %}{{ content|urlize_quoted_links }}")
+        rendered = template.render(Context({'content': '<script>alert()</script> http://example.com'}))
+        assert rendered == '&lt;script&gt;alert()&lt;/script&gt;' \
+                           ' <a href="http://example.com" rel="nofollow">http://example.com</a>'
+
     def test_template_render_with_noautoescape(self):
         """
         Test if the autoescape value is getting passed to urlize_quoted_links filter.
         """
         template = Template("{% load rest_framework %}"
                             "{% autoescape off %}{{ content|urlize_quoted_links }}"
                             "{% endautoescape %}")
-        rendered = template.render(Context({'content': '"http://example.com"'}))
-        assert rendered == '"<a href="http://example.com" rel="nofollow">http://example.com</a>"'
+        rendered = template.render(Context({'content': '<b> "http://example.com" </b>'}))
+        assert rendered == '<b> "<a href="http://example.com" rel="nofollow">http://example.com</a>" </b>'
 
 
 @unittest.skipUnless(coreapi, 'coreapi is not installed')
 class SchemaLinksTests(TestCase):
 

Vulnerable code:                 <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% autoescape off %}{% for key, val in response_headers|items %}
</span>{{ content|urlize_quoted_links }}</pre>{% endautoescape %}
                if autoescape and not safe_input:
                    lead, trail = escape(lead), escape(trail)
                    url, trimmed = escape(url), escape(trimmed)
                words[i] = mark_safe('%s%s%s' % (lead, middle, trail))
                if safe_input:
                    words[i] = mark_safe(word)
                elif autoescape:
                    words[i] = escape(word)
        elif safe_input:
            words[i] = mark_safe(word)
        elif autoescape:
            words[i] = escape(word)
    return ''.join(words)
        rendered = template.render(Context({'content': '"http://example.com"'}))
        assert rendered == '"<a href="http://example.com" rel="nofollow">http://example.com</a>"'
Fixed code:                 <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% for key, val in response_headers|items %}
</span>{{ content|urlize_quoted_links }}</pre>

    # Unfortunately, Django built-in cannot be used here, because escaping
    # is to be performed on words, which have been forcibly coerced to text
    def conditional_escape(text):
        return escape(text) if autoescape and not safe_input else text

                lead, trail = conditional_escape(lead), conditional_escape(trail)
                url, trimmed = conditional_escape(url), conditional_escape(trimmed)
                words[i] = '%s%s%s' % (lead, middle, trail)
                words[i] = conditional_escape(word)
        else:
            words[i] = conditional_escape(word)
    return mark_safe(''.join(words))
    def test_template_render_with_autoescape(self):
        """
        Test that HTML is correctly escaped in Browsable API views.
        """
        template = Template("{% load rest_framework %}{{ content|urlize_quoted_links }}")
        rendered = template.render(Context({'content': '<script>alert()</script> http://example.com'}))
        assert rendered == '&lt;script&gt;alert()&lt;/script&gt;' \
                           ' <a href="http://example.com" rel="nofollow">http://example.com</a>'

        rendered = template.render(Context({'content': '<b> "http://example.com" </b>'}))
        assert rendered == '<b> "<a href="http://example.com" rel="nofollow">http://example.com</a>" </b>'