"""
Proof of Concept for an XSS on the Technical 500 debug page.

I'm only considering this a *potential* security issue due to the fact that sensitive
variables output on the technical 500 are scrubbed. This relies on so much
user footgunning that it shouldn't actually be something that can happen, but
hey ho.

Requirements:
------------
- DEBUG=True to get the technical 500
- Templates much be constructed from at least partial user data.
- TemplateDoesNotExist (or possibly TemplateSyntaxError ... somehow) must be
  raised, and the message needs to somehow contain user data.

The only way I've managed to establish it could even happen is:
- Developer's template system allows construction of templates (e.g Database loader,
  or fragments joined together from form output in the admin to dynamically
  create CMS templates or whatever)
- Developer's template system must allow for dynamic extends, and for reasons
  unknown isn't using variable resolution for it, but is instead doing something
  akin to: data = '{{% extends "{}" %}}'.format(template_name), and is
  allowing the user to select the base template (e.g. from a dropdown) but isn't
  sanitising it (e.g. with a form's ChoicesField)
- Developer has left DEBUG turned on

Taken altogether, it's a wildly unlikely scenario, and realistically if DEBUG
is on there are probably other problems.


Demo
----

- Run this file by doing `python te500xss.py runserver`
- Navigate to the /syntax URL to see the apparently intentional
  self-inflicted XSS. That's not the XSS we care about...
- Visit /block and /include to see normal expected behaviour; the former doesn't
  error, the latter does error but does so safely (despite being the same
  error message).
- Visit /extends and see the actual XSS on the 500 error page.


How it works:
------------
Given the string {% extends "anything" %} the "anything" token part is
turned into a Variable (or a FilterExpression wrapping over one). The Variable
is detected as a 'constant' within Variable.__init__ and as such is stored
as self.literal, and optimistically considered safe via mark_safe().

That it's marked safe is the root of the problem, but unfortunately, that
appears to be a partially undocumented function of DTL - that {{ '<html>' }}
isn't escaped - and tests fail if it's removed. It is documented that the RHS
of filters work that way, and if you extrapolate enough it's readily apparent,
but it's never expressly said or demonstrated.

Anyway, from there onwards it's a SafeString containing our XSS. The SafeString
is given directly to find_template, which goes to the Engine's find_template.
The Engine.find_template will raise TemplateDoesNotExist and provide the SafeString
as the `name` argument, which is also `args[0]` - the latter is important shortly.

Because a TemplateDoesNotExist is thrown and render_annotated detects
that the engine is in debug mode, it ultimately calls Template.get_exception_info
which ends up doing str(exception.args[0]) ... but str() on a SafeString returns
the same SafeString instance; that is id(str(my_safe_str)) and id(my_safe_str)
are the same, presumably due to a str() optimisation to avoid copying down in
the C.

So if the template name given to the extends tag contains HTML (... how?!) it'll
throw, and then the SafeString gets output in the technical 500 as
{{ template_info.message }} but it's already been marked safe, so the HTML is
written directly, rather than escaped.


Mitigation ideas
----------------
This can be fixed in a couple of ways:
- Forcibly coerce the value to an actual string inside of get_exception_info, by
  doing something like str(exception.args[0]).strip(). That'd work for all
  string subclasses which don't have a custom strip method which does something
  clever.
- add escape() to the str(exception.args[0]) call as has been done with
  self.source on the lines previous.
- Use |force_escape filter in the technical 500; the |escape filter won't work
  because it's actually conditional_escape.
"""
import django
from django.conf import settings
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import path

if not settings.configured:
    settings.configure(
        SECRET_KEY="??????????????????????????????????????????????????????????",
        DEBUG=True,
        INSTALLED_APPS=(),
        ALLOWED_HOSTS=("*"),
        ROOT_URLCONF=__name__,
        MIDDLEWARE=[],
        TEMPLATES=[
            {
                "BACKEND": "django.template.backends.django.DjangoTemplates",
                "DIRS": [],
                "APP_DIRS": False,
                "OPTIONS": {
                    "context_processors": [],
                },
            },
        ],
    )
    django.setup()


def valid_syntax(request) -> HttpResponse:
    """
    This is mostly undocumented in the DTL language docs AFAIK, but apparently
    is intentionally supported. It's referenced as
    https://docs.djangoproject.com/en/4.0/ref/templates/language/#string-literals-and-automatic-escaping
    but only shows it being used on the RHS of a filter, unlike the original
    ticket ( https://code.djangoproject.com/ticket/5945 ) which showed using
    it as the whole, unescaped value.

    The following tests fail if Variable.__init__ is changed so that:
        self.literal = mark_safe(unescape_string_literal(var))
    becomes:
        self.literal = unescape_string_literal(var)

    FAIL: test_i18n16 (template_tests.syntax_tests.i18n.test_underscore_syntax.I18nStringLiteralTests)
    FAIL: test_autoescape_literals01 (template_tests.syntax_tests.test_autoescape.AutoescapeTagTests)
    FAIL: test_basic_syntax26 (template_tests.syntax_tests.test_basic.BasicSyntaxTests)
    FAIL: test_basic_syntax27 (template_tests.syntax_tests.test_basic.BasicSyntaxTests)

    So that's a non-starter.

    If I ever knew about this functionality, I've long since forgotten it, and
    grepping the source for \{\{(\s+?)["'] shows nothing much outside of tests...

    Having {{ '<raw html>' }} work when
    {% templatetag %} AND {% verbatim %} AND {% filter %} seems like a weird
    historic quirk, though of course I may just be missing some use-case or other.

    Anyway, by having a literal become a SafeString, I can later XSS the
    technical 500 page.
    """
    template = Template("""
        This is intentionally supported syntax, but relatively well hidden IMHO.
        <hr>
        {{ '<script>alert(1);</script>' }}
    """)
    return HttpResponse(template.render(Context({})))


def extends(request) -> HttpResponse:
    """
    So in the previous view we saw that we can apparently intentionally have
    raw HTML within variables and it'll parse OK, possibly so you could
    feed it to a filter like {{ '<div>...</div>'|widont }} or something?

    Because the template tag passes the Variable's resolved SafeString to
    find_template, and the technical 500 template outputs template_info.message
    which is actually TemplateDoesNotExist.args[0], which is the name
    argument ... it gets output having been treated as safe ... which it should
    be, ordinarily.
    """
    template = Template("{% extends '<script>alert(1);</script>' %}")
    return HttpResponse(template.render(Context({})))


def include(request) -> HttpResponse:
    """
    Note that the include node doesn't express the same problem.
    This is because the TemplateDoesNotExist uses select_template which
    internally discards the SafeString instance by converting the tuple (which
    DOES contain a SafeString) into a string like so:
        ', '.join(not_found)
    which happens to erase the SafeString somewhere down in C.
    """
    template = Template("{% include '<script>alert(1);</script>' %}")
    return HttpResponse(template.render(Context({})))


def block(request) -> HttpResponse:
    """
    And the block tag doesn't even throw an exception in the first place, because
    you can name a block basically anything and never resolves the name.
    """
    template = Template("{% block '<script>alert(1);</script>' %}{% endblock %}")
    return HttpResponse(template.render(Context({})))


urlpatterns = [
    path("syntax", valid_syntax, name="syntax"),
    path("block", block, name="block"),
    path("include", include, name="include"),
    path("extends", extends, name="extends"),
]


if __name__ == "__main__":
    from django.core import management

    management.execute_from_command_line()
else:
    from django.core.wsgi import get_wsgi_application

    application = get_wsgi_application()
