XSS in django-impersonate 1.9.3 and django-gravatar2 1.4.4
This post details two cross-site scripting (XSS) vulnerabilities I discovered in django-impersonate, and django-gravatar2. I'm writing about them together because they share the same vulnerability, and are similar in other aspects that I'll explain below.
Background
At Read the Docs, we use both packages. While waiting for my PRs to be reviewed, and taking a break from coding, I decided to do a quick security audit of some of our dependencies, since both packages have a relatively small codebase, they were good candidates for a quick review.
django-impersonate
django-impersonate allows you to impersonate other users, really useful for debugging and support.
The vulnerability
After grepping the codebase for common vulnerable patterns, I found this line of code that caught my attention:
def get_redir_field(request): redirect_field_name = settings.REDIRECT_FIELD_NAME if redirect_field_name: nextval = request.GET.get(redirect_field_name, None) if nextval: return mark_safe( u'<input type="hidden" name="{0}" value="{1}"/>'.format( redirect_field_name, nextval, ) ) return u''
You can see that the application is building an HTML input field with the value of a query parameter (if the IMPERSONATE["REDIRECT_FIELD_NAME"]
setting is defined),
and marking it as safe with mark_safe
(Django won't escape it when including it in a template).
The problem arises as the query parameter is controlled by the user, and isn't escaped before being included in the string.
Exploitation
Searching for the usage of the get_redir_field
function,
I found it was used in two views related to listing users:
But only the template
rendered from the search_users
view includes the result of the function.
# impersonate/views.py (search_users) return render( request, template, { 'users': users, 'paginator': paginator, 'page': page, 'page_number': page_number, 'query': query, 'redirect': get_redir_arg(request), 'redirect_field': get_redir_field(request), }, )
<!-- impersonate/templates/impersonate/search_users.html --> <form action="{% url 'impersonate-search' %}" method="GET"> Enter Search Query:<br /> <input type="text" name="q" value="{% if query %}{{ query }}{% endif %}"><br /> {{redirect_field}} <input type="submit" value="Search"><br /> </form>
Assuming the application defined the IMPERSONATE["REDIRECT_FIELD_NAME"]
setting as next
,
the URL used to exploit the vulnerability would be /impersonate/search/?next={payload}
.
Where {payload}
can be:
What this does is:
Uses a
"/>
to close theinput
tag.Injects a script that shows an alert with the current domain.
Opens a new tag so the rest of the HTML is not shown as broken.
The payload injected into the template would look like this:
Proof of concept
I created a proof of concept to demonstrate the vulnerability, so you can see it in action, you just need to have Python and uv installed:
It consists of a simple Django project with django-impersonate==1.9.3
installed,
with the IMPERSONATE["REDIRECT_FIELD_NAME"]
setting defined as next
.
$ git clone https://github.com/stsewd/poc-xss-django-impersonate $ cd poc-xss-django-impersonate $ uv run manage.py migrate # Create a user to log into the application. $ uv run manage.py createsuperuser $ uv run manage.py runserver
Go to
http://127.0.0.1:8000/admin/login/
Log in with the user you created
Go to
http://127.0.0.1:8000/impersonate/search/?next=?next="><script>alert(document.domain)</script><input type="hidden
A pop-up with the domain of the page should appear
Showing an alert is just a simple example, but an attacker can execute any JavaScript code in the context of the user's session.
Mitigation
You should never use mark_safe
with user-controlled content,
if you need to build HTML with user-controlled data outside of a template,
you can use the format_html function,
as you can see in the two commits that fixed the vulnerability:
06991a735f29,
33cb8c77262a.
Timeline
11/06/2024: Found and reported the vulnerability to the maintainer.
13/06/2024: Maintainer replied and confirmed the vulnerability.
14/06/2024: Maintainer released version 1.9.4 with the fix.
django-gravatar2
django-gravatar2 allows you to integrate Gravatar in your project, so you can show the user's avatar based on their email.
The vulnerability
After grepping the codebase for common vulnerable patterns, I found this code that caught my attention:
You can see that the application is building an HTML img
tag with several attributes,
like CSS class, alt text, size, and the URL of the Gravatar image,
and marking it as safe with mark_safe
(Django won't escape it when including it in a template).
Of all these attributes, only the URL is being escaped,
all other values are used as is.
I found that the function is used as a template tag to render the Gravatar image:
For example, you can use it in a template like this:
In this example, the size and the alt text are hardcoded, so there is no way for an attacker to inject arbitrary HTML. But what happens if the size or alt text come from the user? Then we have a problem, as the values are not escaped before being included in the template.
Exploitation
Since the vulnerability is in a template tag, exploiting the vulnerability will depend if the application uses the template tag with user-controlled content. We can assume that a common alt text is the user's name.
Then the attacker can inject the payload in the user's name. A simple payload could be:
What this does is:
Uses a
"/>
to close theimg
tag.Injects a script that shows an alert with the current domain.
Opens a new tag so the rest of the HTML is not shown as broken.
The payload injected into the template would look like this:
<img class="gravatar" src="https://www.gravatar.com/" width="50" height="50" alt=""/> <script>alert(document.domain)</script> <img src="" />
But that's very similar to the previous example, so let's assume that the application uses the user's email as the alt text instead.
You may think that's the same as the previous example, but now the payload needs to be a valid email. And if you try to create an email with the previous payload, it won't work, as the Django user model will validate the email format.
Making the payload a valid email is not as simple as just adding @example.com
at the end,
as the part before the @
(local part) can't contain special characters like "<>()
,
which are part of the payload.
Luckily, the spec says that the local part can contain any ASCII characters if it's quoted,
and coincidentally, our payload has already quotes around it, so it's just a matter adding @example.com
at the end!
Or almost... Django's email validator does allow the local part to be quoted, but it doesn't allow spaces,
luckily HTML is very forgiving, so we can add almost anything instead of the spaces, and our payload will still work
You could also leave the tag unclosed, but that will break the rest of the HTML in the template.
Proof of concept
I created a proof of concept to demonstrate the vulnerability, so you can see it in action, you just need to have Python and uv installed:
It consists of a simple Django project with django-gravatar2==1.4.4
installed,
it shows the Gravatar of a user given its email.
$ git clone https://github.com/stsewd/poc-xss-django-gravatar2 $ cd poc-xss-django-gravatar2 $ uv run manage.py migrate $ uv run manage.py runserver
Go to
http://127.0.0.1:8000/
In the form enter
"/><script>alert(document.domain)</script><img src="
as the name, or"/><script>alert(document.domain)</script><img/src="@example.com
as the email.Click on the "Submit" button.
A pop-up with the domain of the page should appear.
Showing an alert is just a simple example, but an attacker can execute any JavaScript code in the context of the user's session.
Mitigation
As the previous vulnerability,
you should never use mark_safe
with user-controlled content,
if you need to build HTML with user-controlled data outside of a template,
you can use the format_html function.
Timeline
21/06/2024: Found and reported the vulnerability to the maintainer.
21/06/2024: Maintainer replied and confirmed the vulnerability.
29/08/2024: Maintainer released version 1.4.5 with the fix.
More in common than you think
Apart from sharing the same vulnerability, there are other similarities between the two packages:
Widely used packages. At the time of writing, django-impersonate had 220K downloads in the last month, and django-gravatar2 had 32K downloads in the last month.
Mostly maintained by a single person.
Not actively maintained.
While the functionality that both packages provide is very specific, they may be considered complete and stable without the need for active development. But as with any software, there is always room for improvement, or updates to keep up with the latest versions of Python and Django.
If you or your company use these packages, please consider contributing to them in any way you can. Another thing these packages have in common is that they are looking for maintainers, so if you have the time and knowledge, consider helping them.
Acknowledgements
Thanks to Peter Sanchez (maintainer of django-impersonate), and Tristan Waddington (maintainer of django-gravatar2) for their quick responses and fixes.
It's also great I have the support at Read the Docs to spend part of my work time on security audits on packages we use. Even if the vulnerabilities don't affect our systems directly, it's nice to have the chance to give back to the community.
Comments
Comments powered by Disqus