diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 5865332e..795b212d 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -1093,6 +1093,34 @@ def insert_funding_yml_donation_links(apps): break +def safe_walk(top, followLinksTo): + """Run os.walk, only following symlinks that stay within followLinksTo. + + Entries whose symlink target escapes the followLinksTo directory + are dropped with a warning, so symlinks in an app's source repo + cannot leak files from the build host into the published index. + """ + followLinksTo = os.path.realpath(followLinksTo) + + def _filter(root, names): + kept = [] + for name in names: + target = os.path.realpath(os.path.join(root, name)) + if target == followLinksTo or target.startswith(followLinksTo + os.sep): + kept.append(name) + else: + logging.warning( + _('Ignoring "{path}": symlink points outside the app source') + .format(path=os.path.join(root, name)) + ) + return kept + + for root, dirs, files in os.walk(top, followlinks=True): + dirs[:] = _filter(root, dirs) + files[:] = _filter(root, files) + yield root, dirs, files + + def copy_triple_t_store_metadata(apps): """Include store metadata from the app's source repo. @@ -1162,9 +1190,10 @@ def copy_triple_t_store_metadata(apps): # Flutter-style android subdir gradle_subdirs.update(glob.glob(os.path.join('build', packageName, 'android', 'app', 'src', '*', 'play'))) + checkout = os.path.join('build', packageName) for d in sorted(gradle_subdirs): logging.debug('Triple-T Gradle Play Publisher: ' + d) - for root, dirs, files in os.walk(d): + for root, dirs, files in safe_walk(d, followLinksTo=checkout): segments = root.split('/') if segments[-2] == 'listings' or segments[-2] == 'release-notes': locale = segments[-1] @@ -1262,7 +1291,9 @@ def insert_localized_app_metadata(apps): for srcd in sorted(sourcedirs): if not os.path.isdir(srcd): continue - for root, dirs, files in os.walk(srcd): + topdir, packageName = srcd.split('/')[:2] + checkout = os.path.join(topdir, packageName) + for root, dirs, files in safe_walk(srcd, followLinksTo=checkout): segments = root.split('/') packageName = segments[1] if packageName not in apps: diff --git a/tests/test_update.py b/tests/test_update.py index c39d770b..3d5e4fe0 100755 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -653,6 +653,52 @@ class UpdateTest(SetUpTearDownMixin, unittest.TestCase): self.assertEqual(app['localized']['en-US']['name'], 'Goguma') self.assertEqual(app['localized']['en-US']['summary'], 'An IRC client for mobile devices') + def _triple_t_app(self, packageName): + """Write minimal metadata and return the Triple-T play dir.""" + os.chdir(self.testdir) + Path('metadata').mkdir(exist_ok=True) + Path('metadata', packageName + '.yml').write_text( + 'CurrentVersionCode: 1\n' + 'Builds:\n - versionCode: 1\n gradle:\n - yes\n' + ) + play = Path('build', packageName, 'src', 'main', 'play') + play.mkdir(parents=True) + return play + + def _copy_triple_t(self, packageName): + config = dict() + fdroidserver.common.fill_config_defaults(config) + fdroidserver.common.config = config + fdroidserver.update.config = config + apps = fdroidserver.metadata.read_metadata() + fdroidserver.update.copy_triple_t_store_metadata(apps) + return apps[packageName].get('localized', {}).get('en-US', {}) + + @unittest.skipUnless(hasattr(os, 'symlink'), 'requires symlink support') + def test_insert_triple_t_symlink_within_checkout(self): + """A listings dir symlinked inside the checkout is still read.""" + packageName = 'com.example.app' + play = self._triple_t_app(packageName) + target = Path('build', packageName, 'store-metadata', 'listings', 'en-US') + target.mkdir(parents=True) + (target / 'full-description.txt').write_text('good description') + os.symlink(os.path.relpath(target.parent, play), play / 'listings') + self.assertEqual(self._copy_triple_t(packageName).get('description'), + 'good description') + + @unittest.skipUnless(hasattr(os, 'symlink'), 'requires symlink support') + def test_insert_triple_t_symlink_outside_checkout(self): + """A listing symlinked outside the checkout must not be published.""" + packageName = 'com.example.app' + play = self._triple_t_app(packageName) + en_US = play / 'listings' / 'en-US' + en_US.mkdir(parents=True) + secret = Path('secret.txt') + secret.write_text('SECRET') + os.symlink(os.path.abspath(secret), en_US / 'full-description.txt') + self.assertNotIn('SECRET', + self._copy_triple_t(packageName).get('description', '')) + def testBadGetsig(self): """getsig() should still be able to fetch the fingerprint of bad signatures""" # config needed to use jarsigner and keytool