diff --git a/CHANGES b/CHANGES index 394b5abdf..fd2b37e89 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,8 @@ Version 0.12 (feature release, released on X) +- Added option to restrict remote repository access to specific path(s): + ``attic serve --restrict-to-path X`` (#51) - Include "all archives" size information in "--stats" output. (#54) - Switch to SI units (Power of 1000 instead 1024) when printing file sizes - Added "--stats" option to 'attic delete' and 'attic prune' diff --git a/attic/archiver.py b/attic/archiver.py index 584d1c4ca..5916012c2 100644 --- a/attic/archiver.py +++ b/attic/archiver.py @@ -47,8 +47,10 @@ class Archiver: else: print(msg, end=' ') - def do_serve(self): - return RepositoryServer().serve() + def do_serve(self, args): + """Start Attic in server mode. This command is usually not used manually. + """ + return RepositoryServer(restrict_to_paths=args.restrict_to_paths).serve() def do_init(self, args): """Initialize an empty repository @@ -431,14 +433,18 @@ Type "Yes I am sure" if you understand this and want to continue.\n""") help='verbose output') # We can't use argparse for "serve" since we don't want it to show up in "Available commands" - if args and args[0] == 'serve': - return self.do_serve() if args: args = self.preprocess_args(args) parser = argparse.ArgumentParser(description='Attic %s - Deduplicated Backups' % __version__) subparsers = parser.add_subparsers(title='Available commands') + subparser = subparsers.add_parser('serve', parents=[common_parser], + description=self.do_serve.__doc__) + subparser.set_defaults(func=self.do_serve) + subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append', + metavar='PATH', help='restrict repository access to PATH') + subparser = subparsers.add_parser('init', parents=[common_parser], description=self.do_init.__doc__) subparser.set_defaults(func=self.do_init) diff --git a/attic/remote.py b/attic/remote.py index b6bfd99c0..7a0fb3b97 100644 --- a/attic/remote.py +++ b/attic/remote.py @@ -18,10 +18,15 @@ class ConnectionClosed(Error): """Connection closed by remote host""" +class PathNotAllowed(Error): + """Repository path not allowed""" + + class RepositoryServer(object): - def __init__(self): + def __init__(self, restrict_to_paths): self.repository = None + self.restrict_to_paths = restrict_to_paths def serve(self): # Make stdin non-blocking @@ -61,11 +66,19 @@ class RepositoryServer(object): path = os.fsdecode(path) if path.startswith('/~'): path = path[1:] - self.repository = Repository(os.path.expanduser(path), create) + path = os.path.realpath(os.path.expanduser(path)) + if self.restrict_to_paths: + for restrict_to_path in self.restrict_to_paths: + if path.startswith(os.path.realpath(restrict_to_path)): + break + else: + raise PathNotAllowed(path) + self.repository = Repository(path, create) return self.repository.id class RemoteRepository(object): + extra_test_args = [] class RPCError(Exception): @@ -83,7 +96,7 @@ class RemoteRepository(object): self.unpacker = msgpack.Unpacker(use_list=False) self.p = None if location.host == '__testsuite__': - args = [sys.executable, '-m', 'attic.archiver', 'serve'] + args = [sys.executable, '-m', 'attic.archiver', 'serve'] + self.extra_test_args else: args = ['ssh'] if location.port: @@ -139,6 +152,8 @@ class RemoteRepository(object): raise Repository.CheckNeeded(self.location.orig) elif error == b'IntegrityError': raise IntegrityError(res) + elif error == b'PathNotAllowed': + raise PathNotAllowed(*res) raise self.RPCError(error) else: yield res diff --git a/attic/testsuite/archiver.py b/attic/testsuite/archiver.py index 0db71cb51..e3f8934d4 100644 --- a/attic/testsuite/archiver.py +++ b/attic/testsuite/archiver.py @@ -13,6 +13,7 @@ from attic.archive import Archive, ChunkBuffer from attic.archiver import Archiver from attic.crypto import bytes_to_long, num_aes_blocks from attic.helpers import Manifest +from attic.remote import RemoteRepository, PathNotAllowed from attic.repository import Repository from attic.testsuite import AtticTestCase from attic.testsuite.mock import patch @@ -403,3 +404,13 @@ class ArchiverCheckTestCase(ArchiverTestCaseBase): class RemoteArchiverTestCase(ArchiverTestCase): prefix = '__testsuite__:' + + def test_remote_repo_restrict_to_path(self): + self.attic('init', self.repository_location) + path_prefix = os.path.dirname(self.repository_path) + with patch.object(RemoteRepository, 'extra_test_args', ['--restrict-to-path', '/foo']): + self.assert_raises(PathNotAllowed, lambda: self.attic('init', self.repository_location + '_1')) + with patch.object(RemoteRepository, 'extra_test_args', ['--restrict-to-path', path_prefix]): + self.attic('init', self.repository_location + '_2') + with patch.object(RemoteRepository, 'extra_test_args', ['--restrict-to-path', '/foo', '--restrict-to-path', path_prefix]): + self.attic('init', self.repository_location + '_3')