CVE-2018-10895: Cross-Site Request Forgery (CSRF) (CWE-352)
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.
CWE description: 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.
CVSS v3 base score: 8.8
Published: 2018-07-12T12:29Z
Repository: https://github.com/qutebrowser/qutebrowser
Language: Python
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 date: 2018-07-11 17:05:23 +0200
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

Diff: diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index b3787e5d0..05a708336 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -30,13 +30,21 @@ import os
 import time
 import textwrap
 import mimetypes
 import urllib
 import collections
+import base64
+
+try:
+    import secrets
+except ImportError:
+    # New in Python 3.6
+    secrets = None
 
 import pkg_resources
 from PyQt5.QtCore import QUrlQuery, QUrl
+from PyQt5.QtNetwork import QNetworkReply
 
 import qutebrowser
 from qutebrowser.config import config, configdata, configexc, configdiff
 from qutebrowser.utils import (version, utils, jinja, log, message, docutils,
                                objreg, urlutils)
@@ -44,10 +52,11 @@ from qutebrowser.misc import objects
 from qutebrowser.qt import sip
 
 
 pyeval_output = ":pyeval was never called"
 spawn_output = ":spawn was never called"
+csrf_token = None
 
 
 _HANDLERS = {}
 
 
@@ -447,16 +456,33 @@ def _qute_settings_set(url):
 
 
 @add_handler('settings')
 def qute_settings(url):
     """Handler for qute://settings. View/change qute configuration."""
+    global csrf_token
+
     if url.path() == '/set':
+        if url.password() != csrf_token:
+            message.error("Invalid CSRF token for qute://settings!")
+            raise QuteSchemeError("Invalid CSRF token!",
+                                  QNetworkReply.ContentAccessDenied)
         return _qute_settings_set(url)
 
+    # 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')
+
     src = jinja.render('settings.html', title='settings',
                        configdata=configdata,
-                       confget=config.instance.get_str)
+                       confget=config.instance.get_str,
+                       csrf_token=csrf_token)
     return 'text/html', src
 
 
 @add_handler('bindings')
 def qute_bindings(_url):
diff --git a/qutebrowser/browser/webengine/interceptor.py b/qutebrowser/browser/webengine/interceptor.py
index 18386eed8..4ea27adf3 100644
--- a/qutebrowser/browser/webengine/interceptor.py
+++ b/qutebrowser/browser/webengine/interceptor.py
@@ -17,10 +17,11 @@
 # You should have received a copy of the GNU General Public License
 # along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.
 
 """A request interceptor taking care of adblocking and custom headers."""
 
+from PyQt5.QtCore import QUrl
 from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor,
                                    QWebEngineUrlRequestInfo)
 
 from qutebrowser.config import config
 from qutebrowser.browser import shared
@@ -67,10 +68,22 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
                                   info.requestUrl().toDisplayString(),
                                   info.firstPartyUrl().toDisplayString(),
                                   resource_type, navigation_type))
 
         url = info.requestUrl()
+        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
 
         # FIXME:qtwebengine only block ads for NavigationTypeOther?
         if self._host_blocker.is_blocked(url):
             log.webview.info("Request to {} blocked by host blocker.".format(
                 url.host()))
diff --git a/qutebrowser/browser/webengine/webenginequtescheme.py b/qutebrowser/browser/webengine/webenginequtescheme.py
index 2434a8889..3eb7c7df1 100644
--- a/qutebrowser/browser/webengine/webenginequtescheme.py
+++ b/qutebrowser/browser/webengine/webenginequtescheme.py
@@ -53,12 +53,32 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
         if url.scheme() in ['chrome-error', 'chrome-extension']:
             # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-63378
             job.fail(QWebEngineUrlRequestJob.UrlInvalid)
             return
 
-        assert job.requestMethod() == b'GET'
+        # 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
+
         assert url.scheme() == 'qute'
+
         log.misc.debug("Got request for {}".format(url.toDisplayString()))
         try:
             mimetype, data = qutescheme.data_for_url(url)
         except qutescheme.NoHandlerFound:
             log.misc.debug("No handler found for {}".format(
diff --git a/qutebrowser/browser/webkit/network/filescheme.py b/qutebrowser/browser/webkit/network/filescheme.py
index 840ed6a4a..a29674e25 100644
--- a/qutebrowser/browser/webkit/network/filescheme.py
+++ b/qutebrowser/browser/webkit/network/filescheme.py
@@ -109,15 +109,17 @@ def dirbrowser_html(path):
     html = jinja.render('dirbrowser.html', title=title, url=path,
                         parent=parent, files=files, directories=directories)
     return html.encode('UTF-8', errors='xmlcharrefreplace')
 
 
-def handler(request):
+def handler(request, _operation, _current_url):
     """Handler for a file:// URL.
 
     Args:
         request: QNetworkRequest to answer to.
+        _operation: The HTTP operation being done.
+        _current_url: The page we're on currently.
 
     Return:
         A QNetworkReply for directories, None for files.
     """
     path = request.url().toLocalFile()
diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py
index ad58cc984..0406f8bdf 100644
--- a/qutebrowser/browser/webkit/network/networkmanager.py
+++ b/qutebrowser/browser/webkit/network/networkmanager.py
@@ -371,17 +371,10 @@ class NetworkManager(QNetworkAccessManager):
             if proxy_error is not None:
                 return networkreply.ErrorNetworkReply(
                     req, proxy_error, QNetworkReply.UnknownProxyError,
                     self)
 
-        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
-
         for header, value in shared.custom_headers(url=req.url()):
             req.setRawHeader(header, value)
 
         host_blocker = objreg.get('host-blocker')
         if host_blocker.is_blocked(req.url()):
@@ -414,7 +407,14 @@ class NetworkManager(QNetworkAccessManager):
             log.webview.debug("{} {}, first-party {}".format(
                 operation,
                 req.url().toDisplayString(),
                 current_url.toDisplayString()))
 
+        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
+
         self.set_referer(req, current_url)
         return super().createRequest(op, req, outgoing_data)
diff --git a/qutebrowser/browser/webkit/network/webkitqutescheme.py b/qutebrowser/browser/webkit/network/webkitqutescheme.py
index d732b6ab0..b6f99437a 100644
--- a/qutebrowser/browser/webkit/network/webkitqutescheme.py
+++ b/qutebrowser/browser/webkit/network/webkitqutescheme.py
@@ -19,31 +19,50 @@
 
 """QtWebKit specific qute://* handlers and glue code."""
 
 import mimetypes
 
-from PyQt5.QtNetwork import QNetworkReply
+from PyQt5.QtCore import QUrl
+from PyQt5.QtNetwork import QNetworkReply, QNetworkAccessManager
 
 from qutebrowser.browser import pdfjs, qutescheme
 from qutebrowser.browser.webkit.network import networkreply
 from qutebrowser.utils import log, usertypes, qtutils
 
 
-def handler(request):
+def handler(request, operation, current_url):
     """Scheme handler for qute:// URLs.
 
     Args:
         request: QNetworkRequest to answer to.
+        operation: The HTTP operation being done.
+        current_url: The page we're on currently.
 
     Return:
         A QNetworkReply.
     """
+    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)
+
     try:
-        mimetype, data = qutescheme.data_for_url(request.url())
+        mimetype, data = qutescheme.data_for_url(url)
     except qutescheme.NoHandlerFound:
-        errorstr = "No handler found for {}!".format(
-            request.url().toDisplayString())
+        errorstr = "No handler found for {}!".format(url.toDisplayString())
         return networkreply.ErrorNetworkReply(
             request, errorstr, QNetworkReply.ContentNotFoundError)
     except qutescheme.QuteSchemeOSError as e:
         return networkreply.ErrorNetworkReply(
             request, str(e), QNetworkReply.ContentNotFoundError)
diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html
index 62b424a59..d4ff4ce34 100644
--- a/qutebrowser/html/settings.html
+++ b/qutebrowser/html/settings.html
@@ -1,11 +1,12 @@
 {% extends "base.html" %}
 
 {% block script %}
 var cset = function(option, value) {
   // FIXME:conf we might want some error handling here?
-  var url = "qute://settings/set?option=" + encodeURIComponent(option);
+  var url = "qute://user:{{csrf_token}}@settings/set"
+  url += "?option=" + encodeURIComponent(option);
   url += "&value=" + encodeURIComponent(value);
   var xhr = new XMLHttpRequest();
   xhr.open("GET", url);
   xhr.send();
 }
diff --git a/tests/end2end/data/misc/qutescheme_csrf.html b/tests/end2end/data/misc/qutescheme_csrf.html
new file mode 100644
index 000000000..66c8fe240
--- /dev/null
+++ b/tests/end2end/data/misc/qutescheme_csrf.html
@@ -0,0 +1,20 @@
+<!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>
diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature
index 1abaadd87..74b11b344 100644
--- a/tests/end2end/features/qutescheme.feature
+++ b/tests/end2end/features/qutescheme.feature
@@ -128,10 +128,67 @@ Feature: Special qute:// pages
         And I press the key "<Escape>"
         # an explicit Tab to unfocus the input field seems to stabilize the tests
         And I press the key "<Tab>"
         Then "Invalid value 'foo' *" should be logged
 
+    @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
+
     # pdfjs support
 
     @qtwebengine_skip: pdfjs is not implemented yet
     Scenario: pdfjs is used for pdf files
         Given pdfjs is available
diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py
index fbab681a4..5a2e15048 100644
--- a/tests/end2end/test_invocations.py
+++ b/tests/end2end/test_invocations.py
@@ -366,12 +366,14 @@ def test_loading_empty_session(tmpdir, request, quteproc_new):
 
 def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
     """Make sure settings from qute://settings are persistent."""
     args = _base_args(request.config) + ['--basedir', str(short_tmpdir)]
     quteproc_new.start(args)
-    quteproc_new.open_path(
-        'qute://settings/set?option=search.ignore_case&value=always')
+    quteproc_new.open_path('qute://settings/')
+    quteproc_new.send_cmd(':jseval --world main '
+                          'cset("search.ignore_case", "always")')
+
     assert quteproc_new.get_setting('search.ignore_case') == 'always'
 
     quteproc_new.send_cmd(':quit')
     quteproc_new.wait_for_quit()
 
diff --git a/tests/unit/browser/webkit/network/test_filescheme.py b/tests/unit/browser/webkit/network/test_filescheme.py
index 5bdbb47cc..2654097ea 100644
--- a/tests/unit/browser/webkit/network/test_filescheme.py
+++ b/tests/unit/browser/webkit/network/test_filescheme.py
@@ -246,28 +246,28 @@ class TestDirbrowserHtml:
 class TestFileSchemeHandler:
 
     def test_dir(self, tmpdir):
         url = QUrl.fromLocalFile(str(tmpdir))
         req = QNetworkRequest(url)
-        reply = filescheme.handler(req)
+        reply = filescheme.handler(req, None, None)
         # The URL will always use /, even on Windows - so we force this here
         # too.
         tmpdir_path = str(tmpdir).replace(os.sep, '/')
         assert reply.readAll() == filescheme.dirbrowser_html(tmpdir_path)
 
     def test_file(self, tmpdir):
         filename = tmpdir / 'foo'
         filename.ensure()
         url = QUrl.fromLocalFile(str(filename))
         req = QNetworkRequest(url)
-        reply = filescheme.handler(req)
+        reply = filescheme.handler(req, None, None)
         assert reply is None
 
     def test_unicode_encode_error(self, mocker):
         url = QUrl('file:///tmp/foo')
         req = QNetworkRequest(url)
 
         err = UnicodeEncodeError('ascii', '', 0, 2, 'foo')
         mocker.patch('os.path.isdir', side_effect=err)
 
-        reply = filescheme.handler(req)
+        reply = filescheme.handler(req, None, None)
         assert reply is None

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)