CVE-2018-10895 (Python, published 2018-07-12T12:29Z)
Description: value: qutebrowser before version 1.4.1 is vulnerable to a cross-site request forgery flaw that allows websites to access 'qute://*' URLs. A malicious website could exploit this to load a 'qute://settings/set' URL, which then sets 'editor.command' to a bash script, resulting in arbitrary code execution.
Severity: MEDIUM (CVSS v3 base score: 8.8)
Weakness: CWE-352 \u2014 Cross-Site Request Forgery (CSRF)
The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
Repository: https://github.com/qutebrowser/qutebrowser
Files changed:
qutebrowser/browser/qutescheme.py
qutebrowser/browser/webengine/interceptor.py
qutebrowser/browser/webengine/webenginequtescheme.py
qutebrowser/browser/webkit/network/filescheme.py
qutebrowser/browser/webkit/network/networkmanager.py
qutebrowser/browser/webkit/network/webkitqutescheme.py
qutebrowser/html/settings.html
tests/end2end/data/misc/qutescheme_csrf.html
tests/end2end/features/qutescheme.feature
tests/end2end/test_invocations.py
tests/unit/browser/webkit/network/test_filescheme.py
Commit message: CVE-2018-10895: Fix CSRF issues with qute://settings/set URL

In ffc29ee043ae7336d9b9dcc029a05bf7a3f994e8 (part of v1.0.0), a
qute://settings/set URL was added to change settings.

Contrary to what I apparently believed at the time, it *is* possible for
websites to access `qute://*` URLs (i.e., neither QtWebKit nor QtWebEngine
prohibit such requests, other than the usual cross-origin rules).

In other words, this means a website can e.g. have an `<img>` tag which loads a
`qute://settings/set` URL, which then sets `editor.command` to a bash script.
The result of that is arbitrary code execution.

Fixes #4060
See #2332


Vulnerable code:
                       confget=config.instance.get_str)
        assert job.requestMethod() == b'GET'
def handler(request):
        scheme = req.url().scheme()
        if scheme in self._scheme_handlers:
            result = self._scheme_handlers[scheme](req)
            if result is not None:
                result.setParent(self)
                return result

from PyQt5.QtNetwork import QNetworkReply
def handler(request):
        mimetype, data = qutescheme.data_for_url(request.url())
        errorstr = "No handler found for {}!".format(
            request.url().toDisplayString())
  var url = "qute://settings/set?option=" + encodeURIComponent(option);
    quteproc_new.open_path(
        'qute://settings/set?option=search.ignore_case&value=always')
        reply = filescheme.handler(req)
        reply = filescheme.handler(req)
        reply = filescheme.handler(req)

Fixed code:
import base64

try:
    import secrets
except ImportError:
    # New in Python 3.6
    secrets = None
from PyQt5.QtNetwork import QNetworkReply
csrf_token = None
    global csrf_token

        if url.password() != csrf_token:
            message.error("Invalid CSRF token for qute://settings!")
            raise QuteSchemeError("Invalid CSRF token!",
                                  QNetworkReply.ContentAccessDenied)
    # Requests to qute://settings/set should only be allowed from
    # qute://settings. As an additional security precaution, we generate a CSRF
    # token to use here.
    if secrets:
        csrf_token = secrets.token_urlsafe()
    else:
        # On Python < 3.6, from secrets.py
        token = base64.urlsafe_b64encode(os.urandom(32))
        csrf_token = token.rstrip(b'=').decode('ascii')

                       confget=config.instance.get_str,
                       csrf_token=csrf_token)
from PyQt5.QtCore import QUrl
        firstparty = info.firstPartyUrl()

        if ((url.scheme(), url.host(), url.path()) ==
                ('qute', 'settings', '/set')):
            if (firstparty != QUrl('qute://settings/') or
                    info.resourceType() !=
                    QWebEngineUrlRequestInfo.ResourceTypeXhr):
                log.webview.warning("Blocking malicious request from {} to {}"
                                    .format(firstparty.toDisplayString(),
                                            url.toDisplayString()))
                info.block(True)
                return
        # Only the browser itself or qute:// pages should access any of those
        # URLs.
        # The request interceptor further locks down qute://settings/set.
        try:
            initiator = job.initiator()
        except AttributeError:
            # Added in Qt 5.11
            pass
        else:
            if initiator.isValid() and initiator.scheme() != 'qute':
                log.misc.warning("Blocking malicious request from {} to {}"
                                 .format(initiator.toDisplayString(),
                                         url.toDisplayString()))
                job.fail(QWebEngineUrlRequestJob.RequestDenied)
                return

        if job.requestMethod() != b'GET':
            job.fail(QWebEngineUrlRequestJob.RequestDenied)
            return


def handler(request, _operation, _current_url):
        _operation: The HTTP operation being done.
        _current_url: The page we're on currently.
        scheme = req.url().scheme()
        if scheme in self._scheme_handlers:
            result = self._scheme_handlers[scheme](req, op, current_url)
            if result is not None:
                result.setParent(self)
                return result

from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkReply, QNetworkAccessManager
def handler(request, operation, current_url):
        operation: The HTTP operation being done.
        current_url: The page we're on currently.
    if operation != QNetworkAccessManager.GetOperation:
        return networkreply.ErrorNetworkReply(
            request, "Unsupported request type",
            QNetworkReply.ContentOperationNotPermittedError)

    url = request.url()

    if ((url.scheme(), url.host(), url.path()) ==
            ('qute', 'settings', '/set')):
        if current_url != QUrl('qute://settings/'):
            log.webview.warning("Blocking malicious request from {} to {}"
                                .format(current_url.toDisplayString(),
                                        url.toDisplayString()))
            return networkreply.ErrorNetworkReply(
                request, "Invalid qute://settings request",
                QNetworkReply.ContentAccessDenied)

        mimetype, data = qutescheme.data_for_url(url)
        errorstr = "No handler found for {}!".format(url.toDisplayString())
  var url = "qute://user:{{csrf_token}}@settings/set"
  url += "?option=" + encodeURIComponent(option);
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>CSRF issues with qute://settings</title>
        <script type="text/javascript">
            function add_img() {
                const elem = document.createElement("img")
                elem.src = "qute://settings/set?option=auto_save.interval&value=invalid";
                document.body.appendChild(elem);
            }
        </script>
    </head>
    <body>
        <form action="qute://settings/set?option=auto_save.interval&value=invalid" method="post"><button type="submit" id="via-form">Via form</button></form>
        <input type="button" onclick="add_img()" value="Via img" id="via-img">
        <a href="qute://settings/set?option=auto_save.interval&value=invalid" id="via-link">Via link</a>
        <a href="/redirect-to?url=qute://settings/set%3Foption=auto_save.interval%26value=invalid" id="via-redirect">Via redirect</a>
    </body>
</html>
    @qtwebkit_skip
    Scenario: qute://settings CSRF via img (webengine)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-img
        Then "Blocking malicious request from http://localhost:*/data/misc/qutescheme_csrf.html to qute://settings/set?*" should be logged

    @qtwebkit_skip
    Scenario: qute://settings CSRF via link (webengine)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-link
        Then "Blocking malicious request from qute://settings/set?* to qute://settings/set?*" should be logged

    @qtwebkit_skip
    Scenario: qute://settings CSRF via redirect (webengine)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-redirect
        Then "Blocking malicious request from qute://settings/set?* to qute://settings/set?*" should be logged

    @qtwebkit_skip
    Scenario: qute://settings CSRF via form (webengine)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-form
        Then "Blocking malicious request from qute://settings/set?* to qute://settings/set?*" should be logged

    @qtwebkit_skip
    Scenario: qute://settings CSRF token (webengine)
        When I open qute://settings
        And I run :jseval const xhr = new XMLHttpRequest(); xhr.open("GET", "qute://settings/set"); xhr.send()
        Then "Error while handling qute://* URL" should be logged
        And the error "Invalid CSRF token for qute://settings!" should be shown

    @qtwebengine_skip
    Scenario: qute://settings CSRF via img (webkit)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-img
        Then "Blocking malicious request from http://localhost:*/data/misc/qutescheme_csrf.html to qute://settings/set?*" should be logged

    @qtwebengine_skip
    Scenario: qute://settings CSRF via link (webkit)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-link
        Then "Blocking malicious request from http://localhost:*/data/misc/qutescheme_csrf.html to qute://settings/set?*" should be logged
        And "Error while loading qute://settings/set?*: Invalid qute://settings request" should be logged

    @qtwebengine_skip
    Scenario: qute://settings CSRF via redirect (webkit)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-redirect
        Then "Blocking malicious request from http://localhost:*/data/misc/qutescheme_csrf.html to qute://settings/set?*" should be logged
        And "Error while loading qute://settings/set?*: Invalid qute://settings request" should be logged

    @qtwebengine_skip
    Scenario: qute://settings CSRF via form (webkit)
        When I open data/misc/qutescheme_csrf.html
        And I run :click-element id via-form
        Then "Error while loading qute://settings/set?*: Unsupported request type" should be logged

    quteproc_new.open_path('qute://settings/')
    quteproc_new.send_cmd(':jseval --world main '
                          'cset("search.ignore_case", "always")')

        reply = filescheme.handler(req, None, None)
        reply = filescheme.handler(req, None, None)
        reply = filescheme.handler(req, None, None)