CVE-2018-10895: Fix CSRF issues with qute://settings/set URL

In ffc29ee043 (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
This commit is contained in:
Florian Bruhin 2018-07-09 23:38:47 +02:00
parent 3b9b2bc30e
commit 43e58ac865
11 changed files with 181 additions and 21 deletions

View File

@ -32,9 +32,17 @@ import textwrap
import mimetypes import mimetypes
import urllib import urllib
import collections import collections
import base64
try:
import secrets
except ImportError:
# New in Python 3.6
secrets = None
import pkg_resources import pkg_resources
from PyQt5.QtCore import QUrlQuery, QUrl from PyQt5.QtCore import QUrlQuery, QUrl
from PyQt5.QtNetwork import QNetworkReply
import qutebrowser import qutebrowser
from qutebrowser.config import config, configdata, configexc, configdiff from qutebrowser.config import config, configdata, configexc, configdiff
@ -46,6 +54,7 @@ from qutebrowser.qt import sip
pyeval_output = ":pyeval was never called" pyeval_output = ":pyeval was never called"
spawn_output = ":spawn was never called" spawn_output = ":spawn was never called"
csrf_token = None
_HANDLERS = {} _HANDLERS = {}
@ -449,12 +458,29 @@ def _qute_settings_set(url):
@add_handler('settings') @add_handler('settings')
def qute_settings(url): def qute_settings(url):
"""Handler for qute://settings. View/change qute configuration.""" """Handler for qute://settings. View/change qute configuration."""
global csrf_token
if url.path() == '/set': 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) 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', src = jinja.render('settings.html', title='settings',
configdata=configdata, configdata=configdata,
confget=config.instance.get_str) confget=config.instance.get_str,
csrf_token=csrf_token)
return 'text/html', src return 'text/html', src

View File

@ -19,6 +19,7 @@
"""A request interceptor taking care of adblocking and custom headers.""" """A request interceptor taking care of adblocking and custom headers."""
from PyQt5.QtCore import QUrl
from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor, from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor,
QWebEngineUrlRequestInfo) QWebEngineUrlRequestInfo)
@ -69,6 +70,18 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor):
resource_type, navigation_type)) resource_type, navigation_type))
url = info.requestUrl() 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? # FIXME:qtwebengine only block ads for NavigationTypeOther?
if self._host_blocker.is_blocked(url): if self._host_blocker.is_blocked(url):

View File

@ -55,8 +55,28 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler):
job.fail(QWebEngineUrlRequestJob.UrlInvalid) job.fail(QWebEngineUrlRequestJob.UrlInvalid)
return 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' assert url.scheme() == 'qute'
log.misc.debug("Got request for {}".format(url.toDisplayString())) log.misc.debug("Got request for {}".format(url.toDisplayString()))
try: try:
mimetype, data = qutescheme.data_for_url(url) mimetype, data = qutescheme.data_for_url(url)

View File

@ -111,11 +111,13 @@ def dirbrowser_html(path):
return html.encode('UTF-8', errors='xmlcharrefreplace') return html.encode('UTF-8', errors='xmlcharrefreplace')
def handler(request): def handler(request, _operation, _current_url):
"""Handler for a file:// URL. """Handler for a file:// URL.
Args: Args:
request: QNetworkRequest to answer to. request: QNetworkRequest to answer to.
_operation: The HTTP operation being done.
_current_url: The page we're on currently.
Return: Return:
A QNetworkReply for directories, None for files. A QNetworkReply for directories, None for files.

View File

@ -373,13 +373,6 @@ class NetworkManager(QNetworkAccessManager):
req, proxy_error, QNetworkReply.UnknownProxyError, req, proxy_error, QNetworkReply.UnknownProxyError,
self) 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()): for header, value in shared.custom_headers(url=req.url()):
req.setRawHeader(header, value) req.setRawHeader(header, value)
@ -416,5 +409,12 @@ class NetworkManager(QNetworkAccessManager):
req.url().toDisplayString(), req.url().toDisplayString(),
current_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) self.set_referer(req, current_url)
return super().createRequest(op, req, outgoing_data) return super().createRequest(op, req, outgoing_data)

View File

@ -21,27 +21,46 @@
import mimetypes 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 import pdfjs, qutescheme
from qutebrowser.browser.webkit.network import networkreply from qutebrowser.browser.webkit.network import networkreply
from qutebrowser.utils import log, usertypes, qtutils from qutebrowser.utils import log, usertypes, qtutils
def handler(request): def handler(request, operation, current_url):
"""Scheme handler for qute:// URLs. """Scheme handler for qute:// URLs.
Args: Args:
request: QNetworkRequest to answer to. request: QNetworkRequest to answer to.
operation: The HTTP operation being done.
current_url: The page we're on currently.
Return: Return:
A QNetworkReply. 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: try:
mimetype, data = qutescheme.data_for_url(request.url()) mimetype, data = qutescheme.data_for_url(url)
except qutescheme.NoHandlerFound: except qutescheme.NoHandlerFound:
errorstr = "No handler found for {}!".format( errorstr = "No handler found for {}!".format(url.toDisplayString())
request.url().toDisplayString())
return networkreply.ErrorNetworkReply( return networkreply.ErrorNetworkReply(
request, errorstr, QNetworkReply.ContentNotFoundError) request, errorstr, QNetworkReply.ContentNotFoundError)
except qutescheme.QuteSchemeOSError as e: except qutescheme.QuteSchemeOSError as e:

View File

@ -3,7 +3,8 @@
{% block script %} {% block script %}
var cset = function(option, value) { var cset = function(option, value) {
// FIXME:conf we might want some error handling here? // 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); url += "&value=" + encodeURIComponent(value);
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open("GET", url); xhr.open("GET", url);

View File

@ -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>

View File

@ -130,6 +130,63 @@ Feature: Special qute:// pages
And I press the key "<Tab>" And I press the key "<Tab>"
Then "Invalid value 'foo' *" should be logged 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 # pdfjs support
@qtwebengine_skip: pdfjs is not implemented yet @qtwebengine_skip: pdfjs is not implemented yet

View File

@ -368,8 +368,10 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
"""Make sure settings from qute://settings are persistent.""" """Make sure settings from qute://settings are persistent."""
args = _base_args(request.config) + ['--basedir', str(short_tmpdir)] args = _base_args(request.config) + ['--basedir', str(short_tmpdir)]
quteproc_new.start(args) quteproc_new.start(args)
quteproc_new.open_path( quteproc_new.open_path('qute://settings/')
'qute://settings/set?option=search.ignore_case&value=always') quteproc_new.send_cmd(':jseval --world main '
'cset("search.ignore_case", "always")')
assert quteproc_new.get_setting('search.ignore_case') == 'always' assert quteproc_new.get_setting('search.ignore_case') == 'always'
quteproc_new.send_cmd(':quit') quteproc_new.send_cmd(':quit')

View File

@ -248,7 +248,7 @@ class TestFileSchemeHandler:
def test_dir(self, tmpdir): def test_dir(self, tmpdir):
url = QUrl.fromLocalFile(str(tmpdir)) url = QUrl.fromLocalFile(str(tmpdir))
req = QNetworkRequest(url) 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 # The URL will always use /, even on Windows - so we force this here
# too. # too.
tmpdir_path = str(tmpdir).replace(os.sep, '/') tmpdir_path = str(tmpdir).replace(os.sep, '/')
@ -259,7 +259,7 @@ class TestFileSchemeHandler:
filename.ensure() filename.ensure()
url = QUrl.fromLocalFile(str(filename)) url = QUrl.fromLocalFile(str(filename))
req = QNetworkRequest(url) req = QNetworkRequest(url)
reply = filescheme.handler(req) reply = filescheme.handler(req, None, None)
assert reply is None assert reply is None
def test_unicode_encode_error(self, mocker): def test_unicode_encode_error(self, mocker):
@ -269,5 +269,5 @@ class TestFileSchemeHandler:
err = UnicodeEncodeError('ascii', '', 0, 2, 'foo') err = UnicodeEncodeError('ascii', '', 0, 2, 'foo')
mocker.patch('os.path.isdir', side_effect=err) mocker.patch('os.path.isdir', side_effect=err)
reply = filescheme.handler(req) reply = filescheme.handler(req, None, None)
assert reply is None assert reply is None