Repository: bit-team/backintime

Issue:
Permissions (modes) of log files and fileinfo are too lax
This is a minor security issue in BiT. The log files and `fileinfo.bz2` files in snapshot directories, as well as the log file stored in `.local/share/backintime/`, are readable by any user (if they are stored in a directory that allows that). Reading these files can be used to determine the contents of directories one normally can't access. 

For example, my backups include the directory `/root` among other directories. Because the permissions (the mode) of `/root` is `drwx------`, `ls /root` as non-root outputs:
```
ls: cannot open directory '/root': Permission denied
```
A non-privileged user with access to the backups can run the following to list the files in `/root`:
```
$ cd [snapshots directory]
$ bzcat last_snapshot/fileinfo.bz2 | grep ' /root/'
```

A solution would be to explicitly set the permisions of the log files and `fileinfo.bz2` to `-rw-------`.


Discussion:
Hello Samo,

Thank you for taking the time to report the bug and providing the details. This is an excellent report. I appreciate your feedback.

I confirm the issue you described and considering a solution to the best of my ability.

My approach would be to set owner-only read-write permissions (`-rw------` or is it `600` in octal?) for the following files:
- `fileinfo.bz2`
- `takesnapshot.log.bz2`
- `info`

Based on my current knowledge and assessment I wouldn't back port the solution to previous releases.

May I suggest a workaround: The permissions for the backup folder are not touched by BIT nor by rsync itself (#377 is IMHO not a bug and wont get fixed). The umask settings are responsible for this. So you might modify the umask before (and after) doing a backup; e.g. via callback script. If you try it, let me know if this work.

If you have any more details to share, feel free to reach out.

Not sure when we'll find the time to work on it. Please see the projects background information to get an idea about our workflow and priorities:

- [Maintenance status](https://github.com/bit-team/backintime#maintenance-status)
- [The team](https://github.com/bit-team/backintime#the-team)
- [Strategy outline / Roadmap](https://github.com/bit-team/backintime/blob/dev/CONTRIBUTING.md#strategy-outline)

Best regards,
Christian
Hello, thank you for your quick reply. Don't worry about not fixing this issue early; I do not use upstream BiT, but the version packaged by Debian, so it means I won't be getting any upgrades for two years from now. 

I do not think restricting `info` is necessary; the information there is not particularly private.

Thanks for the umask tip. I haven't tried it out yet.

Also, I tried fixing this issue myself yesterday. The solution appeared to work, but the tests complained about `fileinfo.bz2` not being readable. (I tested my modification on a minimal virtual machine. Apart from this issue, there were a lot of different errors in the tests, e.g. ssh didn't work at all, and various cryptic messages and warnings. These also affected unmodified BiT. I may file report with information how to reproduce this later.)
> but the version packaged by Debian, so it means I won't be getting any upgrades for two years from now.

If there is a good reason, a big bad security bug, they would accept not only patches but new upstream versions (e.g. 1.5.6) in "stable" branch.

> I do not think restricting `info` is necessary; the information there is not particularly private.

OK.

> Also, I tried fixing this issue myself yesterday.

I am in the middle of coding. And I also take the opportunity to refactor some stuff.
If you think your modification has some value you can open a PullRequest and I'll have a look.
> If there is a good reason, a big bad security bug, they would accept not only patches but new upstream versions (e.g. 1.5.6) in "stable" branch.

Sorry, I was unclear; I meant upgrades regarding this issue (which I think is far from severe enough for a Debian security upgrade).

> I am in the middle of coding. And I also take the opportunity to refactor some stuff.
If you think your modification has some value you can open a PullRequest and I'll have a look.

I don't think my code is of much value. The only slightly non-trivial thing about it is that I used the `opener` argument of `open()`.



Patch:
diff --git a/CHANGES b/CHANGES
index 338d4abb9..2fa9dda75 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,7 @@
 Back In Time
 
 Version 1.6.0-dev (development of upcoming release)
+* Changed: Stricter permissions (600) for fileinfo.bz2 and takesnapshot.log.bz2 (#2235)
 * Changed: **Breaking** A "snapshot" now is a "backup" (#1929)
 * Changed: **Breaking** Deprecated and removed make targets: "test", "test-v", "unittest", "unittest-v"
 * Changed: Unlocking ssh-agent use "force" instead of "prefer" for SSH_ASKPASS_REQUIRE (#2170) (@daviewales)
diff --git a/common/snapshots.py b/common/snapshots.py
index cac4269d6..b6ffa7bc3 100644
--- a/common/snapshots.py
+++ b/common/snapshots.py
@@ -2702,7 +2702,7 @@ def withoutTag(self):
         """
         return self.sid[0:15]
 
-    def path(self, *path, use_mode = []):
+    def path(self, *path, use_mode = []) -> str:
         """
         Current path of this snapshot automatically altered for
         remote/encrypted version of this path
@@ -2914,7 +2914,7 @@ def info(self, i):
         i.save(self.path(self.INFO))
 
     @property
-    def fileInfo(self):
+    def fileInfo(self) -> FileInfoDict:
         """
         Load/save "fileinfo.bz2"
 
@@ -2926,47 +2926,69 @@ def fileInfo(self):
         """
         d = FileInfoDict()
         infoFile = self.path(self.FILEINFO)
+
         if not os.path.isfile(infoFile):
             return d
 
         try:
             with bz2.BZ2File(infoFile, 'rb') as fileinfo:
+
                 for line in fileinfo:
                     line = line.strip(b'\n')
+
                     if not line:
                         continue
+
                     index = line.find(b'/')
+
                     if index < 0:
                         continue
+
                     f = line[index:]
+
                     if not f:
                         continue
+
                     info = line[:index].strip().split(b' ')
+
                     if len(info) == 3:
-                        d[f] = (int(info[0]), info[1], info[2]) #perms, user, group
-        except (FileNotFoundError, PermissionError) as e:
-            logger.error('Failed to load {} from snapshot {}: {}'.format(
-                         self.FILEINFO, self.sid, str(e)),
+                        d[f] = (
+                            int(info[0]),  # perms
+                            info[1],  # user
+                            info[2]  # group
+                        )
+
+        except (FileNotFoundError, PermissionError) as exc:
+            logger.error(f'Failed to load {self.FILEINFO} from snapshot '
+                         f'{self.sid}: {exc}',
                          self)
         return d
 
     @fileInfo.setter
-    def fileInfo(self, d):
-        assert isinstance(d, FileInfoDict), 'd is not FileInfoDict type: {}'.format(d)
+    def fileInfo(self, fi_dict: FileInfoDict):
+        fp = Path(self.path(self.FILEINFO))
+
         try:
-            with bz2.BZ2File(self.path(self.FILEINFO), 'wb') as f:
-                for path, info in d.items():
-                    f.write(b' '.join((str(info[0]).encode('utf-8', 'replace'),
-                                       info[1],
-                                       info[2],
-                                       path))
-                                       + b'\n')
-        except PermissionError as e:
-            logger.error('Failed to write {}: {}'.format(self.FILEINFO, str(e)))
-
-    # TODO use @property decorator? IMHO not because it is not a "getter" but processes data
+            with bz2.BZ2File(str(fp), 'wb') as handle:
+
+                for path, info in fi_dict.items():
+                    handle.write(b' '.join((
+                        str(info[0]).encode('utf-8', 'replace'),
+                        info[1],
+                        info[2],
+                        path)) + b'\n')
+
+            # Owner only permissions
+            fp.chmod(0o600)  # -rw-------
+
+        except PermissionError as exc:
+            logger.error(f'Failed to write "{fp}": {exc}')
+
+    # TODO use @property decorator? IMHO not because it is not
+    # a "getter" but processes data
     # TODO Should have an action name like "loadLogFile"
-    def log(self, mode = None, decode = None):
+    def log(self, mode: int = None, decode: encfstools.Decode = None
+            ) -> Generator[str, None, None]:
         """
         Load log from "takesnapshot.log.bz2"
 
@@ -2980,37 +3002,48 @@ def log(self, mode = None, decode = None):
         """
         logFile = self.path(self.LOG)
         logFilter = snapshotlog.LogFilter(mode, decode)
+
         try:
             with bz2.BZ2File(logFile, 'rb') as f:
+
                 if logFilter.header:
                     yield logFilter.header
+
                 for line in f.readlines():
                     line = logFilter.filter(line.decode('utf-8').rstrip('\n'))
+
                     if not line is None:
+
                         yield line
-        except Exception as e:
+
+        except FileNotFoundError as e:
             msg = ('Failed to get snapshot log from {}:'.format(logFile), str(e))
             logger.debug(' '.join(msg), self)
+
             for line in msg:
                 yield line
 
-    def setLog(self, log):
-        """
-        Write log to "takesnapshot.log.bz2"
+    def setLog(self, content: str | bytes) -> None:
+        """Write log to "takesnapshot.log.bz2"
 
         Args:
-            log: full snapshot log
+            content: full snapshot log
         """
-        if isinstance(log, str):
-            log = log.encode('utf-8', 'replace')
-        logFile = self.path(self.LOG)
+        if isinstance(content, str):
+            content = content.encode('utf-8', 'replace')
+
+        log_fp = Path(self.path(self.LOG))
+
         try:
-            with bz2.BZ2File(logFile, 'wb') as f:
-                f.write(log)
-        except Exception as e:
-            logger.error('Failed to write log into compressed file {}: {}'.format(
-                         logFile, str(e)),
-                         self)
+            with bz2.BZ2File(str(log_fp), 'wb') as handle:
+                handle.write(content)
+
+            # Owner only permissions
+            log_fp.chmod(0o600)  # -rw-------
+
+
+        except PermissionError as exc:
+            logger.error(f'Failed to write log into "{log_fp}": {exc}')
 
     def makeWritable(self):
         """


Test patch:
diff --git a/common/test/generic.py b/common/test/generic.py
index 9b7394b72..d62e81e35 100644
--- a/common/test/generic.py
+++ b/common/test/generic.py
@@ -426,10 +426,18 @@ def create_test_files(path):
 
 
 @contextmanager
-def mockPermissions(path, mode=0o000):
-    st = os.stat(path)
-    os.chmod(path, mode)
-    yield
+def mockPermissions(path: pathlib.Path | str, mode: int = 0o000) -> None:
+    # Workaround
+    if isinstance(path, str):
+        path = pathlib.Path(path)
 
-    # fix permissions so it can be removed
-    os.chmod(path, st.st_mode)
+    # extract permission bits only (mask out file type)
+    org_perms = path.stat().st_mode & 0o777
+
+    path.chmod(mode)
+
+    try:
+        yield
+
+    finally:
+        path.chmod(org_perms)
diff --git a/common/test/test_file_info_dict.py b/common/test/test_file_info_dict.py
new file mode 100644
index 000000000..8b9099558
--- /dev/null
+++ b/common/test/test_file_info_dict.py
@@ -0,0 +1,101 @@
+# SPDX-FileCopyrightText: © 2008-2022 Oprea Dan
+# SPDX-FileCopyrightText: © 2008-2022 Bart de Koning
+# SPDX-FileCopyrightText: © 2008-2022 Richard Bailey
+# SPDX-FileCopyrightText: © 2008-2022 Germar Reitze
+# SPDX-FileCopyrightText: © 2025 Christian BUHTZ <c.buhtz@posteo.jp>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# This file is part of the program "Back In Time" which is released under GNU
+# General Public License v2 (GPLv2). See LICENSES directory or go to
+# <https://spdx.org/licenses/GPL-2.0-or-later.html>.
+#
+# Split from test_sid.py
+"""Test about FileInfoDict class and files"""
+import stat
+from pathlib import Path
+from test import generic
+from unittest.mock import patch
+import snapshots
+
+
+class FileInfoTests(generic.SnapshotsTestCase):
+    """Testinthe fileinfo.bz2 file"""
+    def test_created_and_reloaded(self):
+        """Create info file and reload it in a new SID instance"""
+        # Create SID (backup/snapshot) and its directory
+        sid_name = '20151219-010324-123'
+        sid1 = snapshots.SID(sid_name, self.cfg)
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        info_file_fp = sid_path / snapshots.SID.FILEINFO
+
+        fi_dict = snapshots.FileInfoDict()
+        fi_dict[b'/tmp'] = (123, b'foo', b'bar')
+        fi_dict[b'/tmp/foo'] = (456, b'asdf', b'qwer')
+        sid1.fileInfo = fi_dict
+
+        self.assertTrue(info_file_fp.is_file())
+
+        # load fileInfo in a new snapshot
+        sid2 = snapshots.SID(sid_name, self.cfg)
+        self.assertDictEqual(sid2.fileInfo, fi_dict)
+
+    @patch('logger.error')
+    def test_read_error(self, mock_logger):
+        """Error if info file is empty"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        info_file_fp = Path(sid.path(snapshots.SID.FILEINFO))
+        info_file_fp.touch()
+
+        with generic.mockPermissions(info_file_fp):
+            # file info is empty
+            self.assertEqual(sid.fileInfo, snapshots.FileInfoDict())
+            # error because it was empty
+            self.assertTrue(mock_logger.called)
+
+    @patch('logger.error')
+    def test_write_error(self, mock_logger):
+        """Error while writing info file"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        info_file_fp = Path(sid.path(snapshots.SID.FILEINFO))
+        info_file_fp.touch()
+
+        with generic.mockPermissions(info_file_fp):
+            fi_dict = snapshots.FileInfoDict()
+            fi_dict[b'/tmp'] = (123, b'foo', b'bar')
+            fi_dict[b'/tmp/foo'] = (456, b'asdf', b'qwer')
+
+            sid.fileInfo = fi_dict
+
+            self.assertTrue(mock_logger.called)
+
+    def test_owner_only_permissions(self):
+        """File permissions only to the owner"""
+        # Create SID (backup/snapshot) and its directory
+        sid_name = '20151219-010324-123'
+        sid1 = snapshots.SID(sid_name, self.cfg)
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        info_file_fp = sid_path / snapshots.SID.FILEINFO
+        info_file_fp.touch()
+
+        fi_dict = snapshots.FileInfoDict()
+        sid1.fileInfo = fi_dict
+
+        # Owner only permissions
+        self.assertEqual(
+            stat.filemode(info_file_fp.stat().st_mode),
+            '-rw-------'
+        )
diff --git a/common/test/test_lint.py b/common/test/test_lint.py
index 2bc440305..20e86f5ee 100644
--- a/common/test/test_lint.py
+++ b/common/test/test_lint.py
@@ -60,11 +60,13 @@
     'version.py',
     'test/test_args.py',
     'test/test_diagnostics.py',
+    'test/test_file_info_dict.py',
     'test/test_languages.py',
     'test/test_lint.py',
     'test/test_mount.py',
     'test/test_singleton.py',
     'test/test_storagesize.py',
+    'test/test_takesnapshotlog.py',
     'test/test_uniquenessset.py',
 )]
 
diff --git a/common/test/test_sid.py b/common/test/test_sid.py
index b64c6d195..fa76f7b65 100644
--- a/common/test/test_sid.py
+++ b/common/test/test_sid.py
@@ -9,20 +9,16 @@
 # General Public License v2 (GPLv2). See LICENSES directory or go to
 # <https://spdx.org/licenses/GPL-2.0-or-later.html>.
 import os
-import sys
 import unittest
 import stat
 from datetime import date, datetime
 from test import generic
-from unittest.mock import patch
-
-sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
 import configfile
 import snapshots
-from snapshotlog import LogFilter, SnapshotLog
+from snapshotlog import SnapshotLog
 
 
-class TestSID(generic.SnapshotsTestCase):
+class General(generic.SnapshotsTestCase):
     def test_new_object_with_valid_date(self):
         sid1 = snapshots.SID('20151219-010324-123', self.cfg)
         sid2 = snapshots.SID('20151219-010324', self.cfg)
@@ -286,94 +282,6 @@ def test_info(self):
         i2 = sid2.info
         self.assertEqual(i2.strValue('foo', 'default'), 'bar')
 
-    def test_fileInfo(self):
-        sid1 = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        infoFile = os.path.join(self.snapshotPath,
-                                '20151219-010324-123',
-                                'fileinfo.bz2')
-
-        d = snapshots.FileInfoDict()
-        d[b'/tmp']     = (123, b'foo', b'bar')
-        d[b'/tmp/foo'] = (456, b'asdf', b'qwer')
-        sid1.fileInfo = d
-
-        self.assertIsFile(infoFile)
-
-        #load fileInfo in a new snapshot
-        sid2 = snapshots.SID('20151219-010324-123', self.cfg)
-        self.assertDictEqual(sid2.fileInfo, d)
-
-    @patch('logger.error')
-    def test_fileInfoErrorRead(self, mock_logger):
-        sid = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        infoFile = sid.path(sid.FILEINFO)
-        # remove all permissions from file
-        with open(infoFile, 'wt'):
-            pass
-
-        with generic.mockPermissions(infoFile):
-            self.assertEqual(sid.fileInfo, snapshots.FileInfoDict())
-            self.assertTrue(mock_logger.called)
-
-    @patch('logger.error')
-    def test_fileInfoErrorWrite(self, mock_logger):
-        sid = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        infoFile = sid.path(sid.FILEINFO)
-        # remove all permissions from file
-        with open(infoFile, 'wt'):
-            pass
-
-        with generic.mockPermissions(infoFile):
-            d = snapshots.FileInfoDict()
-            d[b'/tmp']     = (123, b'foo', b'bar')
-            d[b'/tmp/foo'] = (456, b'asdf', b'qwer')
-            sid.fileInfo = d
-            self.assertTrue(mock_logger.called)
-
-    def test_log(self):
-        sid = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        logFile = os.path.join(self.snapshotPath,
-                               '20151219-010324-123',
-                               'takesnapshot.log.bz2')
-
-        # no log available
-        self.assertRegex('\n'.join(sid.log()),
-                         r'Failed to get snapshot log from.*')
-
-        sid.setLog('foo bar\nbaz')
-        self.assertIsFile(logFile)
-
-        self.assertEqual('\n'.join(sid.log()), 'foo bar\nbaz')
-
-    def test_log_filter(self):
-        sid = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        logFile = os.path.join(self.snapshotPath,
-                               '20151219-010324-123',
-                               'takesnapshot.log.bz2')
-
-        sid.setLog('foo bar\n[I] 123\n[C] baz\n[E] bla')
-        self.assertIsFile(logFile)
-
-        self.assertEqual('\n'.join(sid.log(mode=LogFilter.CHANGES)),
-                         'foo bar\n[C] baz')
-
-    def test_setLog_binary(self):
-        sid = snapshots.SID('20151219-010324-123', self.cfg)
-        os.makedirs(os.path.join(self.snapshotPath, '20151219-010324-123'))
-        logFile = os.path.join(self.snapshotPath,
-                               '20151219-010324-123',
-                               'takesnapshot.log.bz2')
-
-        sid.setLog(b'foo bar\nbaz')
-        self.assertIsFile(logFile)
-
-        self.assertEqual('\n'.join(sid.log()), 'foo bar\nbaz')
-
     def test_makeWritable(self):
         sid = snapshots.SID('20151219-010324-123', self.cfg)
         sidPath = os.path.join(self.snapshotPath,   '20151219-010324-123')
diff --git a/common/test/test_takesnapshotlog.py b/common/test/test_takesnapshotlog.py
new file mode 100644
index 000000000..5335a08f8
--- /dev/null
+++ b/common/test/test_takesnapshotlog.py
@@ -0,0 +1,105 @@
+# SPDX-FileCopyrightText: © 2008-2022 Oprea Dan
+# SPDX-FileCopyrightText: © 2008-2022 Bart de Koning
+# SPDX-FileCopyrightText: © 2008-2022 Richard Bailey
+# SPDX-FileCopyrightText: © 2008-2022 Germar Reitze
+# SPDX-FileCopyrightText: © 2025 Christian BUHTZ <c.buhtz@posteo.jp>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# This file is part of the program "Back In Time" which is released under GNU
+# General Public License v2 (GPLv2). See LICENSES directory or go to
+# <https://spdx.org/licenses/GPL-2.0-or-later.html>.
+#
+# Split from test_sid.py
+"""Tests about takesnapshot.log.bz2 file related to snapshots.SID class"""
+import stat
+from pathlib import Path
+from test import generic
+import snapshots
+import snapshotlog
+
+
+class TakeSnapshotLog(generic.SnapshotsTestCase):
+    """Tests regarding takesnapshot.log file"""
+
+    def test_error_if_not_exist(self):
+        """Log file does not exist"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        result = '\n'.join(sid.log())
+
+        self.assertTrue(result.startswith('Failed to get snapshot log from'))
+
+    def test_content(self):
+        """Write and read content"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        log_path = sid_path / snapshots.SID.LOG
+
+        self.assertFalse(log_path.exists())
+
+        content = 'foo bar\nbaz'
+        sid.setLog(content)
+
+        self.assertTrue(log_path.exists())
+        self.assertTrue(log_path.is_file())
+
+        result = '\n'.join(sid.log())
+
+        self.assertEqual(result, content)
+
+    def test_log_filter(self):
+        """Filter for changes"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        # Content with info, changes and errors
+        sid.setLog('foo bar\n[I] 123\n[C] baz\n[E] bla')
+
+        # content filtered only for changes
+        result = '\n'.join(sid.log(mode=snapshotlog.LogFilter.CHANGES))
+
+        self.assertEqual(result, 'foo bar\n[C] baz')
+
+    def test_write_binary(self):
+        """Write binary but read as text"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        content = b'foo bar\nbaz'
+
+        sid.setLog(content)
+
+        result = '\n'.join(sid.log())
+        self.assertEqual(result, content.decode('utf-8'))
+
+    def test_owner_only_permission(self):
+        """Create log file with permissions only for the owner"""
+        sid_name = '20151219-010324-123'
+        sid = snapshots.SID(sid_name, self.cfg)
+
+        sid_path = Path(self.snapshotPath) / sid_name
+        sid_path.mkdir(parents=True)
+
+        log_path = sid_path / snapshots.SID.LOG
+        sid.setLog('foo bar')
+
+        # Owner only permissions
+        self.assertEqual(
+            stat.filemode(log_path.stat().st_mode),
+            '-rw-------'
+        )