CVE-2020-11054: Incorrect Provision of Specified Functionality (CWE-684)
value: In qutebrowser versions less than 1.11.1, reloading a page with certificate errors shows a green URL. After a certificate error was overridden by the user, qutebrowser displays the URL as yellow (colors.statusbar.url.warn.fg). However, when the affected website was subsequently loaded again, the URL was mistakenly displayed as green (colors.statusbar.url.success_https). While the user already has seen a certificate error prompt at this point (or set content.ssl_strict to false, which is not recommended), this could still provide a false sense of security. This has been fixed in 1.11.1 and 1.12.0. All versions of qutebrowser are believed to be affected, though versions before v0.11.x couldn't be tested. Backported patches for older versions (greater than or equal to 1.4.0 and less than or equal to 1.10.2) are available, but no further releases are planned.
CWE description: The code does not function according to its published specifications, potentially leading to incorrect usage.
CVSS v3 base score: 3.5
Published: 2020-05-07T21:15Z
Repository: https://github.com/qutebrowser/qutebrowser
Language: Python
Files changed: qutebrowser/browser/browsertab.py, qutebrowser/browser/webengine/webenginetab.py, qutebrowser/browser/webkit/webkittab.py
Commit date: 2020-05-02 18:54:05 +0200
Commit message: Security: Remember hosts with ignored cert errors for load status

Without this change, we only set a flag when a certificate error occurred.
However, when the same certificate error then happens a second time (e.g.
because of a reload or opening the same URL again), we then colored the URL as
success_https (i.e. green) again.

See #5403

Diff: diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 7e8ec478f7..6182b685dd 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -867,10 +867,17 @@ class AbstractTab(QWidget):
     # Signal emitted when the underlying renderer process terminated.
     # arg 0: A TerminationStatus member.
     # arg 1: The exit code.
     renderer_process_terminated = pyqtSignal(TerminationStatus, int)
 
+    # Hosts for which a certificate error happened. Shared between all tabs.
+    #
+    # Note that we remember hosts here, without scheme/port:
+    # QtWebEngine/Chromium also only remembers hostnames, and certificates are
+    # for a given hostname anyways.
+    _insecure_hosts = set()  # type: typing.Set[str]
+
     def __init__(self, *, win_id: int, private: bool,
                  parent: QWidget = None) -> None:
         self.is_private = private
         self.win_id = win_id
         self.tab_id = next(tab_id_gen)
@@ -884,11 +891,10 @@ class AbstractTab(QWidget):
 
         self.data = TabData()
         self._layout = miscwidgets.WrapperLayout(self)
         self._widget = typing.cast(QWidget, None)
         self._progress = 0
-        self._has_ssl_errors = False
         self._load_status = usertypes.LoadStatus.none
         self._tab_event_filter = eventfilter.TabEventFilter(
             self, parent=self)
         self.backend = None  # type: typing.Optional[usertypes.Backend]
 
@@ -971,11 +977,10 @@ class AbstractTab(QWidget):
         self.url_changed.emit(url)
 
     @pyqtSlot()
     def _on_load_started(self) -> None:
         self._progress = 0
-        self._has_ssl_errors = False
         self.data.viewing_source = False
         self._set_load_status(usertypes.LoadStatus.loading)
         self.load_started.emit()
 
     @pyqtSlot(usertypes.NavigationRequest)
@@ -1030,13 +1035,16 @@ class AbstractTab(QWidget):
         """Update the load status after a page finished loading.
 
         Needs to be called by subclasses to trigger a load status update, e.g.
         as a response to a loadFinished signal.
         """
-        if ok and not self._has_ssl_errors:
+        if ok:
             if self.url().scheme() == 'https':
-                self._set_load_status(usertypes.LoadStatus.success_https)
+                if self.url().host() in self._insecure_hosts:
+                    self._set_load_status(usertypes.LoadStatus.warn)
+                else:
+                    self._set_load_status(usertypes.LoadStatus.success_https)
             else:
                 self._set_load_status(usertypes.LoadStatus.success)
         elif ok:
             self._set_load_status(usertypes.LoadStatus.warn)
         else:
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index ffe2279883..647fa60abc 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -1547,13 +1547,13 @@ class WebEngineTab(browsertab.AbstractTab):
                 emit_before_load_started=False))
             self._reload_url = None
 
     @pyqtSlot(certificateerror.CertificateErrorWrapper)
     def _on_ssl_errors(self, error):
-        self._has_ssl_errors = True
-
         url = error.url()
+        self._insecure_hosts.add(url.host())
+
         log.webview.debug("Certificate error: {}".format(error))
 
         if error.is_overridable():
             error.ignore = shared.ignore_certificate_errors(
                 url, [error], abort_on=[self.abort_questions])
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 4d412a38b4..d1122b78e1 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -847,13 +847,13 @@ class WebKitTab(browsertab.AbstractTab):
             navigation.accepted = False
 
         if navigation.is_main_frame:
             self.settings.update_for_url(navigation.url)
 
-    @pyqtSlot()
-    def _on_ssl_errors(self):
-        self._has_ssl_errors = True
+    @pyqtSlot('QNetworkReply*')
+    def _on_ssl_errors(self, reply):
+        self._insecure_hosts.add(reply.url().host())
 
     def _connect_signals(self):
         view = self._widget
         page = view.page()
         frame = page.mainFrame()

Vulnerable code:         self._has_ssl_errors = False
        self._has_ssl_errors = False
        if ok and not self._has_ssl_errors:
                self._set_load_status(usertypes.LoadStatus.success_https)
        self._has_ssl_errors = True

    @pyqtSlot()
    def _on_ssl_errors(self):
        self._has_ssl_errors = True
Fixed code:     # Hosts for which a certificate error happened. Shared between all tabs.
    #
    # Note that we remember hosts here, without scheme/port:
    # QtWebEngine/Chromium also only remembers hostnames, and certificates are
    # for a given hostname anyways.
    _insecure_hosts = set()  # type: typing.Set[str]

        if ok:
                if self.url().host() in self._insecure_hosts:
                    self._set_load_status(usertypes.LoadStatus.warn)
                else:
                    self._set_load_status(usertypes.LoadStatus.success_https)
        self._insecure_hosts.add(url.host())

    @pyqtSlot('QNetworkReply*')
    def _on_ssl_errors(self, reply):
        self._insecure_hosts.add(reply.url().host())