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 @@ -32,9 +32,17 @@ 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 @@ -46,6 +54,7 @@ from qutebrowser.qt import sip pyeval_output = ":pyeval was never called" spawn_output = ":spawn was never called" +csrf_token = None _HANDLERS = {} @@ -449,12 +458,29 @@ 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 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 @@ -19,6 +19,7 @@ """A request interceptor taking care of adblocking and custom headers.""" +from PyQt5.QtCore import QUrl from PyQt5.QtWebEngineCore import (QWebEngineUrlRequestInterceptor, QWebEngineUrlRequestInfo) @@ -69,6 +70,18 @@ class RequestInterceptor(QWebEngineUrlRequestInterceptor): 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): 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 @@ -55,8 +55,28 @@ class QuteSchemeHandler(QWebEngineUrlSchemeHandler): 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) 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 @@ -111,11 +111,13 @@ def dirbrowser_html(path): 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. 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 @@ -373,13 +373,6 @@ class NetworkManager(QNetworkAccessManager): 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) @@ -416,5 +409,12 @@ class NetworkManager(QNetworkAccessManager): 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 @@ -21,27 +21,46 @@ 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: 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 @@ -3,7 +3,8 @@ {% 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); 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 @@ + + +
+ +