CVE-2022-23651: Time-of-check Time-of-use (TOCTOU) Race Condition (CWE-367)
value: b2-sdk-python is a python library to access cloud storage provided by backblaze. Linux and Mac releases of the SDK version 1.14.0 and below contain a key disclosure vulnerability that, in certain conditions, can be exploited by local attackers through a time-of-check-time-of-use (TOCTOU) race condition. SDK users of the SqliteAccountInfo format are vulnerable while users of the InMemoryAccountInfo format are safe. The SqliteAccountInfo saves API keys (and bucket name-to-id mapping) in a local database file ($XDG_CONFIG_HOME/b2/account_info, ~/.b2_account_info or a user-defined path). When first created, the file is world readable and is (typically a few milliseconds) later altered to be private to the user. If the directory containing the file is readable by a local attacker then during the brief period between file creation and permission modification, a local attacker can race to open the file and maintain a handle to it. This allows the local attacker to read the contents after the file after the sensitive information has been saved to it. Consumers of this SDK who rely on it to save data using SqliteAccountInfo class should upgrade to the latest version of the SDK. Those who believe a local user might have opened a handle using this race condition, should remove the affected database files and regenerate all application keys. Users should upgrade to b2-sdk-python 1.14.1 or later.
CWE description: The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use in a way that invalidates the results of the check. This can cause the product to perform invalid actions when the resource is in an unexpected state.
CVSS v3 base score: 4.7
Published: 2022-02-23T23:15Z
Repository: https://github.com/Backblaze/b2-sdk-python
Language: Python
Files changed: CHANGELOG.md, b2sdk/account_info/sqlite_account_info.py, test/unit/account_info/test_account_info.py
Commit date: 2022-02-23 10:58:01 -0800
Commit message: Merge pull request from GHSA-p867-fxfr-ph2w

* Fix setting permissions for local sqlite database

Thanks to Jan Schejbal for responsible disclosure!

There used to be a brief moment between creation of the sqlite
database and applying chmod, now there is no such delay.

* Rename self.home to self.test_home in tests

* Actually call the test cleanup

* Add a test for sqlite permissions

* Skip the sqlite chmod test on Windows
Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index e27739b..6238428 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 * Add support for unverified checksum upload mode
 * Add dedicated exception for unverified email
 
 ### Fixed
 * Fix downloading files with unverified checksum
+* Fix setting permissions for local sqlite database (thanks to Jan Schejbal for responsible disclosure!)
 
 ## [1.14.0] - 2021-12-23
 
 ### Fixed
 * Relax constraint on arrow to allow for versions >= 1.0.2
diff --git a/b2sdk/account_info/sqlite_account_info.py b/b2sdk/account_info/sqlite_account_info.py
index 1cd56fb..34694a4 100644
--- a/b2sdk/account_info/sqlite_account_info.py
+++ b/b2sdk/account_info/sqlite_account_info.py
@@ -163,24 +163,28 @@ class SqliteAccountInfo(UrlPoolAccountInfo):
     def _connect(self):
         return sqlite3.connect(self.filename, isolation_level='EXCLUSIVE')
 
     def _create_database(self, last_upgrade_to_run):
         """
-        Make sure that the database is created and sets the file permissions.
+        Make sure that the database is created and has appropriate file permissions.
         This should be done before storing any sensitive data in it.
         """
+        # Prepare a file
+        fd = os.open(
+            self.filename,
+            flags=os.O_RDWR | os.O_CREAT,
+            mode=stat.S_IRUSR | stat.S_IWUSR,
+        )
+        os.close(fd)
         # Create the tables in the database
         conn = self._connect()
         try:
             with conn:
                 self._create_tables(conn, last_upgrade_to_run)
         finally:
             conn.close()
 
-        # Set the file permissions
-        os.chmod(self.filename, stat.S_IRUSR | stat.S_IWUSR)
-
     def _create_tables(self, conn, last_upgrade_to_run):
         conn.execute(
             """
             CREATE TABLE IF NOT EXISTS
             update_done (
diff --git a/test/unit/account_info/test_account_info.py b/test/unit/account_info/test_account_info.py
index e680f05..3aab823 100644
--- a/test/unit/account_info/test_account_info.py
+++ b/test/unit/account_info/test_account_info.py
@@ -12,10 +12,11 @@ from abc import ABCMeta, abstractmethod
 import json
 import unittest.mock as mock
 import os
 import platform
 import shutil
+import stat
 import tempfile
 
 import pytest
 
 from apiver_deps import (
@@ -317,19 +318,33 @@ class TestSqliteAccountInfo(AccountInfoBase):
         ).name
         try:
             os.unlink(self.db_path)
         except OSError:
             pass
-        self.home = tempfile.mkdtemp()
+        self.test_home = tempfile.mkdtemp()
 
         yield
-        for cleanup_method in [lambda: os.unlink(self.db_path), lambda: shutil.rmtree(self.home)]:
+        for cleanup_method in [
+            lambda: os.unlink(self.db_path), lambda: shutil.rmtree(self.test_home)
+        ]:
             try:
-                cleanup_method
+                cleanup_method()
             except OSError:
                 pass
 
+    @pytest.mark.skipif(
+        platform.system() == 'Windows',
+        reason='different permission system on Windows'
+    )
+    def test_permissions(self):
+        """
+        Test that a new database won't be readable by just any user
+        """
+        s = SqliteAccountInfo(file_name=self.db_path,)
+        mode = os.stat(self.db_path).st_mode
+        assert stat.filemode(mode) == '-rw-------'
+
     def test_corrupted(self):
         """
         Test that a corrupted file will be replaced with a blank file.
         """
         with open(self.db_path, 'wb') as f:
@@ -369,51 +384,51 @@ class TestSqliteAccountInfo(AccountInfoBase):
         Returns a new SqliteAccountInfo that has just read the data from the file.
 
         :param dict env: Override Environment variables.
         """
         # Override HOME to ensure hermetic tests
-        with mock.patch('os.environ', env or {'HOME': self.home}):
+        with mock.patch('os.environ', env or {'HOME': self.test_home}):
             return SqliteAccountInfo(
                 file_name=self.db_path if not env else None,
                 last_upgrade_to_run=last_upgrade_to_run,
             )
 
     def test_uses_default(self):
         account_info = self._make_sqlite_account_info(
             env={
-                'HOME': self.home,
-                'USERPROFILE': self.home,
+                'HOME': self.test_home,
+                'USERPROFILE': self.test_home,
             }
         )
         actual_path = os.path.abspath(account_info.filename)
-        assert os.path.join(self.home, '.b2_account_info') == actual_path
+        assert os.path.join(self.test_home, '.b2_account_info') == actual_path
 
     def test_uses_xdg_config_home(self, apiver):
         with WindowsSafeTempDir() as d:
             account_info = self._make_sqlite_account_info(
                 env={
-                    'HOME': self.home,
-                    'USERPROFILE': self.home,
+                    'HOME': self.test_home,
+                    'USERPROFILE': self.test_home,
                     XDG_CONFIG_HOME_ENV_VAR: d,
                 }
             )
             if apiver in ['v0', 'v1']:
-                expected_path = os.path.abspath(os.path.join(self.home, '.b2_account_info'))
+                expected_path = os.path.abspath(os.path.join(self.test_home, '.b2_account_info'))
             else:
                 assert os.path.exists(os.path.join(d, 'b2'))
                 expected_path = os.path.abspath(os.path.join(d, 'b2', 'account_info'))
             actual_path = os.path.abspath(account_info.filename)
             assert expected_path == actual_path
 
     def test_uses_existing_file_and_ignores_xdg(self):
         with WindowsSafeTempDir() as d:
-            default_db_file_location = os.path.join(self.home, '.b2_account_info')
+            default_db_file_location = os.path.join(self.test_home, '.b2_account_info')
             open(default_db_file_location, 'a').close()
             account_info = self._make_sqlite_account_info(
                 env={
-                    'HOME': self.home,
-                    'USERPROFILE': self.home,
+                    'HOME': self.test_home,
+                    'USERPROFILE': self.test_home,
                     XDG_CONFIG_HOME_ENV_VAR: d,
                 }
             )
             actual_path = os.path.abspath(account_info.filename)
             assert default_db_file_location == actual_path
@@ -421,12 +436,12 @@ class TestSqliteAccountInfo(AccountInfoBase):
 
     def test_account_info_env_var_overrides_xdg_config_home(self):
         with WindowsSafeTempDir() as d:
             account_info = self._make_sqlite_account_info(
                 env={
-                    'HOME': self.home,
-                    'USERPROFILE': self.home,
+                    'HOME': self.test_home,
+                    'USERPROFILE': self.test_home,
                     XDG_CONFIG_HOME_ENV_VAR: d,
                     B2_ACCOUNT_INFO_ENV_VAR: os.path.join(d, 'b2_account_info'),
                 }
             )
             expected_path = os.path.abspath(os.path.join(d, 'b2_account_info'))

Vulnerable code:         Make sure that the database is created and sets the file permissions.
        # Set the file permissions
        os.chmod(self.filename, stat.S_IRUSR | stat.S_IWUSR)

        self.home = tempfile.mkdtemp()
        for cleanup_method in [lambda: os.unlink(self.db_path), lambda: shutil.rmtree(self.home)]:
                cleanup_method
        with mock.patch('os.environ', env or {'HOME': self.home}):
                'HOME': self.home,
                'USERPROFILE': self.home,
        assert os.path.join(self.home, '.b2_account_info') == actual_path
                    'HOME': self.home,
                    'USERPROFILE': self.home,
                expected_path = os.path.abspath(os.path.join(self.home, '.b2_account_info'))
            default_db_file_location = os.path.join(self.home, '.b2_account_info')
                    'HOME': self.home,
                    'USERPROFILE': self.home,
                    'HOME': self.home,
                    'USERPROFILE': self.home,
Fixed code: * Fix setting permissions for local sqlite database (thanks to Jan Schejbal for responsible disclosure!)
        Make sure that the database is created and has appropriate file permissions.
        # Prepare a file
        fd = os.open(
            self.filename,
            flags=os.O_RDWR | os.O_CREAT,
            mode=stat.S_IRUSR | stat.S_IWUSR,
        )
        os.close(fd)
import stat
        self.test_home = tempfile.mkdtemp()
        for cleanup_method in [
            lambda: os.unlink(self.db_path), lambda: shutil.rmtree(self.test_home)
        ]:
                cleanup_method()
    @pytest.mark.skipif(
        platform.system() == 'Windows',
        reason='different permission system on Windows'
    )
    def test_permissions(self):
        """
        Test that a new database won't be readable by just any user
        """
        s = SqliteAccountInfo(file_name=self.db_path,)
        mode = os.stat(self.db_path).st_mode
        assert stat.filemode(mode) == '-rw-------'

        with mock.patch('os.environ', env or {'HOME': self.test_home}):
                'HOME': self.test_home,
                'USERPROFILE': self.test_home,
        assert os.path.join(self.test_home, '.b2_account_info') == actual_path
                    'HOME': self.test_home,
                    'USERPROFILE': self.test_home,
                expected_path = os.path.abspath(os.path.join(self.test_home, '.b2_account_info'))
            default_db_file_location = os.path.join(self.test_home, '.b2_account_info')
                    'HOME': self.test_home,
                    'USERPROFILE': self.test_home,
                    'HOME': self.test_home,
                    'USERPROFILE': self.test_home,