From f80d29772a8009305a34fa910c456b718c6fe419 Mon Sep 17 00:00:00 2001 From: psamanoelton Date: Mon, 1 Jun 2026 16:29:57 -0600 Subject: [PATCH 1/3] fix projector plugin vulnerability --- .../plugins/projector/projector_plugin.py | 59 ++++++++--- .../projector/projector_plugin_test.py | 100 ++++++++++++++++++ 2 files changed, 146 insertions(+), 13 deletions(-) diff --git a/tensorboard/plugins/projector/projector_plugin.py b/tensorboard/plugins/projector/projector_plugin.py index 90bf78af1f..f88731b7aa 100644 --- a/tensorboard/plugins/projector/projector_plugin.py +++ b/tensorboard/plugins/projector/projector_plugin.py @@ -210,10 +210,22 @@ def _parse_positive_int_param(request, param_name): def _rel_to_abs_asset_path(fpath, config_fpath): - fpath = os.path.expanduser(fpath) - if not os.path.isabs(fpath): - return os.path.join(os.path.dirname(config_fpath), fpath) - return fpath + config_dir = os.path.realpath( + os.path.dirname(os.path.expanduser(config_fpath)) + ) + candidate = os.path.expanduser(fpath) + if not os.path.isabs(candidate): + candidate = os.path.join(config_dir, candidate) + candidate = os.path.realpath(candidate) + try: + if os.path.commonpath([config_dir, candidate]) != config_dir: + raise ValueError() + except ValueError as e: + raise ValueError( + 'Asset path "%s" resolves outside the config directory' + % fpath + ) from e + return candidate def _using_tf(): @@ -363,9 +375,18 @@ def _augment_configs_with_checkpoint_info(self): embedding.tensor_name = embedding.tensor_name[:-2] # Find the size of embeddings associated with a tensors file. if embedding.tensor_path: - fpath = _rel_to_abs_asset_path( - embedding.tensor_path, self.config_fpaths[run] - ) + try: + fpath = _rel_to_abs_asset_path( + embedding.tensor_path, self.config_fpaths[run] + ) + except ValueError as e: + logger.warning( + 'Skipping tensor path "%s" for run "%s": %s', + embedding.tensor_path, + run, + e, + ) + continue tensor = self.tensor_cache.get((run, embedding.tensor_name)) if tensor is None: try: @@ -594,7 +615,10 @@ def _serve_metadata(self, request): "text/plain", 400, ) - fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + try: + fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + except ValueError as e: + return Respond(request, str(e), "text/plain", 400) if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath): return Respond( request, @@ -651,9 +675,12 @@ def _serve_tensor(self, request): embedding = self._get_embedding(name, config) if embedding and embedding.tensor_path: - fpath = _rel_to_abs_asset_path( - embedding.tensor_path, self.config_fpaths[run] - ) + try: + fpath = _rel_to_abs_asset_path( + embedding.tensor_path, self.config_fpaths[run] + ) + except ValueError as e: + return Respond(request, str(e), "text/plain", 400) if not tf.io.gfile.exists(fpath): return Respond( request, @@ -720,7 +747,10 @@ def _serve_bookmarks(self, request): "text/plain", 400, ) - fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + try: + fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + except ValueError as e: + return Respond(request, str(e), "text/plain", 400) if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath): return Respond( request, @@ -766,7 +796,10 @@ def _serve_sprite_image(self, request): ) fpath = os.path.expanduser(embedding_info.sprite.image_path) - fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + try: + fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run]) + except ValueError as e: + return Respond(request, str(e), "text/plain", 400) if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath): return Respond( request, diff --git a/tensorboard/plugins/projector/projector_plugin_test.py b/tensorboard/plugins/projector/projector_plugin_test.py index 64dd581451..0b4e86a163 100644 --- a/tensorboard/plugins/projector/projector_plugin_test.py +++ b/tensorboard/plugins/projector/projector_plugin_test.py @@ -197,6 +197,60 @@ def testBookmarks(self): bookmark = self._GetJson(url) self.assertEqual(bookmark, {"a": "b"}) + def testMetadataRejectsTraversalOutsideLogdir(self): + outside_metadata_path = os.path.join( + os.path.dirname(self.log_dir), "outside_metadata.tsv" + ) + traversal_path = os.path.relpath(outside_metadata_path, self.log_dir) + self._WriteTextFile(outside_metadata_path, "secret\n") + self._GenerateProjectorAssetsTestData(metadata_path=traversal_path) + self._SetupWSGIApp() + + response = self._Get( + "/data/plugin/projector/metadata?run=.&name=embedding" + ) + self._AssertOutsideConfigDirResponse(response) + + def testTensorRejectsAbsolutePathOutsideLogdir(self): + outside_tensor_path = os.path.join( + os.path.dirname(self.log_dir), "outside_tensor.tsv" + ) + self._WriteTextFile(outside_tensor_path, "1.0\t2.0\n") + self._GenerateProjectorAssetsTestData(tensor_path=outside_tensor_path) + self._SetupWSGIApp() + + response = self._Get("/data/plugin/projector/tensor?run=.&name=embedding") + self._AssertOutsideConfigDirResponse(response) + + def testBookmarksRejectAbsolutePathOutsideLogdir(self): + outside_bookmarks_path = os.path.join( + os.path.dirname(self.log_dir), "outside_bookmarks.json" + ) + self._WriteTextFile(outside_bookmarks_path, '{"label": "secret"}') + self._GenerateProjectorAssetsTestData( + bookmarks_path=outside_bookmarks_path + ) + self._SetupWSGIApp() + + response = self._Get( + "/data/plugin/projector/bookmarks?run=.&name=embedding" + ) + self._AssertOutsideConfigDirResponse(response) + + def testSpriteImageRejectsTraversalOutsideLogdir(self): + outside_sprite_path = os.path.join( + os.path.dirname(self.log_dir), "outside_sprite.png" + ) + traversal_path = os.path.relpath(outside_sprite_path, self.log_dir) + self._WriteTextFile(outside_sprite_path, "not-an-image") + self._GenerateProjectorAssetsTestData(sprite_image_path=traversal_path) + self._SetupWSGIApp() + + response = self._Get( + "/data/plugin/projector/sprite_image?run=.&name=embedding" + ) + self._AssertOutsideConfigDirResponse(response) + def testEndpointsNoAssets(self): g = tf.Graph() @@ -213,6 +267,12 @@ def _AssertTensorResponse(self, tensor_bytes, expected_tensor): ) self.assertTrue(np.array_equal(tensor, expected_tensor)) + def _AssertOutsideConfigDirResponse(self, response): + self.assertEqual(response.status_code, 400) + self.assertIn( + b"resolves outside the config directory", response.data + ) + # TODO(#2007): Cleanly separate out projector tests that require real TF @unittest.skipUnless(USING_REAL_TF, "Test only passes when using real TF") def testPluginIsActive(self): @@ -336,6 +396,46 @@ def _GenerateProjectorTestData(self): ) saver.save(sess, checkpoint_path) + def _GenerateProjectorAssetsTestData( + self, + tensor_path="tensor.tsv", + metadata_path=None, + bookmarks_path=None, + sprite_image_path=None, + ): + self._WriteTextFile( + self._ResolveAssetPath(tensor_path), "1.0\t2.0\n" + ) + + config = projector_config_pb2.ProjectorConfig() + embedding = config.embeddings.add() + embedding.tensor_name = "embedding" + embedding.tensor_path = tensor_path + if metadata_path is not None: + embedding.metadata_path = metadata_path + if bookmarks_path is not None: + embedding.bookmarks_path = bookmarks_path + if sprite_image_path is not None: + embedding.sprite.image_path = sprite_image_path + + with tf.io.gfile.GFile( + os.path.join(self.log_dir, "projector_config.pbtxt"), "w" + ) as f: + f.write(text_format.MessageToString(config)) + + def _ResolveAssetPath(self, path): + path = os.path.expanduser(path) + if os.path.isabs(path): + return os.path.realpath(path) + return os.path.realpath(os.path.join(self.log_dir, path)) + + def _WriteTextFile(self, path, contents): + parent = os.path.dirname(path) + if parent: + tf.io.gfile.makedirs(parent) + with tf.io.gfile.GFile(path, "w") as f: + f.write(contents) + class MetadataColumnsTest(tf.test.TestCase): def testLengthDoesNotMatch(self): From 75bbf22b3d419a7ea18b9d2b8732f243e0720b69 Mon Sep 17 00:00:00 2001 From: psamanoelton Date: Mon, 1 Jun 2026 20:26:41 -0600 Subject: [PATCH 2/3] fix black formatting --- tensorboard/plugins/projector/projector_plugin.py | 3 +-- .../plugins/projector/projector_plugin_test.py | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tensorboard/plugins/projector/projector_plugin.py b/tensorboard/plugins/projector/projector_plugin.py index f88731b7aa..2a9f13b7a3 100644 --- a/tensorboard/plugins/projector/projector_plugin.py +++ b/tensorboard/plugins/projector/projector_plugin.py @@ -222,8 +222,7 @@ def _rel_to_abs_asset_path(fpath, config_fpath): raise ValueError() except ValueError as e: raise ValueError( - 'Asset path "%s" resolves outside the config directory' - % fpath + 'Asset path "%s" resolves outside the config directory' % fpath ) from e return candidate diff --git a/tensorboard/plugins/projector/projector_plugin_test.py b/tensorboard/plugins/projector/projector_plugin_test.py index 0b4e86a163..5fbaec9319 100644 --- a/tensorboard/plugins/projector/projector_plugin_test.py +++ b/tensorboard/plugins/projector/projector_plugin_test.py @@ -219,7 +219,9 @@ def testTensorRejectsAbsolutePathOutsideLogdir(self): self._GenerateProjectorAssetsTestData(tensor_path=outside_tensor_path) self._SetupWSGIApp() - response = self._Get("/data/plugin/projector/tensor?run=.&name=embedding") + response = self._Get( + "/data/plugin/projector/tensor?run=.&name=embedding" + ) self._AssertOutsideConfigDirResponse(response) def testBookmarksRejectAbsolutePathOutsideLogdir(self): @@ -269,9 +271,7 @@ def _AssertTensorResponse(self, tensor_bytes, expected_tensor): def _AssertOutsideConfigDirResponse(self, response): self.assertEqual(response.status_code, 400) - self.assertIn( - b"resolves outside the config directory", response.data - ) + self.assertIn(b"resolves outside the config directory", response.data) # TODO(#2007): Cleanly separate out projector tests that require real TF @unittest.skipUnless(USING_REAL_TF, "Test only passes when using real TF") @@ -403,9 +403,7 @@ def _GenerateProjectorAssetsTestData( bookmarks_path=None, sprite_image_path=None, ): - self._WriteTextFile( - self._ResolveAssetPath(tensor_path), "1.0\t2.0\n" - ) + self._WriteTextFile(self._ResolveAssetPath(tensor_path), "1.0\t2.0\n") config = projector_config_pb2.ProjectorConfig() embedding = config.embeddings.add() From f72e0f2636878aeea7067dcd6ce383b04c042a0c Mon Sep 17 00:00:00 2001 From: psamanoelton Date: Tue, 2 Jun 2026 18:56:57 -0600 Subject: [PATCH 3/3] Solve comments --- .../plugins/projector/projector_plugin.py | 12 +++-- .../projector/projector_plugin_test.py | 49 ++++++++++++++++--- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/tensorboard/plugins/projector/projector_plugin.py b/tensorboard/plugins/projector/projector_plugin.py index 2a9f13b7a3..d0371bd670 100644 --- a/tensorboard/plugins/projector/projector_plugin.py +++ b/tensorboard/plugins/projector/projector_plugin.py @@ -217,13 +217,15 @@ def _rel_to_abs_asset_path(fpath, config_fpath): if not os.path.isabs(candidate): candidate = os.path.join(config_dir, candidate) candidate = os.path.realpath(candidate) + error_message = 'Asset path "%s" resolves outside the config directory' % ( + fpath + ) try: - if os.path.commonpath([config_dir, candidate]) != config_dir: - raise ValueError() + common_path = os.path.commonpath([config_dir, candidate]) except ValueError as e: - raise ValueError( - 'Asset path "%s" resolves outside the config directory' % fpath - ) from e + raise ValueError(error_message) from e + if common_path != config_dir: + raise ValueError(error_message) return candidate diff --git a/tensorboard/plugins/projector/projector_plugin_test.py b/tensorboard/plugins/projector/projector_plugin_test.py index 5fbaec9319..e7f9df9bf3 100644 --- a/tensorboard/plugins/projector/projector_plugin_test.py +++ b/tensorboard/plugins/projector/projector_plugin_test.py @@ -55,7 +55,11 @@ def __init__(self, *args, **kwargs): self.server = None def setUp(self): - self.log_dir = self.get_temp_dir() + self.test_dir = self.get_temp_dir() + self.log_dir = os.path.join(self.test_dir, "log_dir") + self.restricted_dir = os.path.join(self.test_dir, "restricted_dir") + tf.io.gfile.makedirs(self.log_dir) + tf.io.gfile.makedirs(self.restricted_dir) def testRunsWithValidCheckpoint(self): self._GenerateProjectorTestData() @@ -197,11 +201,24 @@ def testBookmarks(self): bookmark = self._GetJson(url) self.assertEqual(bookmark, {"a": "b"}) + def testMetadataServesRelativeFileWithinLogdir(self): + self._GenerateProjectorAssetsTestData(metadata_path="metadata.tsv") + self._WriteTextFile( + os.path.join(self.log_dir, "metadata.tsv"), "label\nvalue\n" + ) + self._SetupWSGIApp() + + response = self._Get( + "/data/plugin/projector/metadata?run=.&name=embedding" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, b"label\nvalue\n") + def testMetadataRejectsTraversalOutsideLogdir(self): outside_metadata_path = os.path.join( - os.path.dirname(self.log_dir), "outside_metadata.tsv" + self.restricted_dir, "outside_metadata.tsv" ) - traversal_path = os.path.relpath(outside_metadata_path, self.log_dir) + traversal_path = "../restricted_dir/outside_metadata.tsv" self._WriteTextFile(outside_metadata_path, "secret\n") self._GenerateProjectorAssetsTestData(metadata_path=traversal_path) self._SetupWSGIApp() @@ -211,9 +228,27 @@ def testMetadataRejectsTraversalOutsideLogdir(self): ) self._AssertOutsideConfigDirResponse(response) + def testMetadataRejectsSymlinkOutsideLogdir(self): + outside_metadata_path = os.path.join( + self.restricted_dir, "outside_metadata.tsv" + ) + symlink_path = os.path.join(self.log_dir, "metadata-link.tsv") + self._WriteTextFile(outside_metadata_path, "secret\n") + try: + os.symlink(outside_metadata_path, symlink_path) + except (AttributeError, NotImplementedError, OSError) as e: + self.skipTest("symlinks unavailable: %s" % e) + self._GenerateProjectorAssetsTestData(metadata_path="metadata-link.tsv") + self._SetupWSGIApp() + + response = self._Get( + "/data/plugin/projector/metadata?run=.&name=embedding" + ) + self._AssertOutsideConfigDirResponse(response) + def testTensorRejectsAbsolutePathOutsideLogdir(self): outside_tensor_path = os.path.join( - os.path.dirname(self.log_dir), "outside_tensor.tsv" + self.restricted_dir, "outside_tensor.tsv" ) self._WriteTextFile(outside_tensor_path, "1.0\t2.0\n") self._GenerateProjectorAssetsTestData(tensor_path=outside_tensor_path) @@ -226,7 +261,7 @@ def testTensorRejectsAbsolutePathOutsideLogdir(self): def testBookmarksRejectAbsolutePathOutsideLogdir(self): outside_bookmarks_path = os.path.join( - os.path.dirname(self.log_dir), "outside_bookmarks.json" + self.restricted_dir, "outside_bookmarks.json" ) self._WriteTextFile(outside_bookmarks_path, '{"label": "secret"}') self._GenerateProjectorAssetsTestData( @@ -241,9 +276,9 @@ def testBookmarksRejectAbsolutePathOutsideLogdir(self): def testSpriteImageRejectsTraversalOutsideLogdir(self): outside_sprite_path = os.path.join( - os.path.dirname(self.log_dir), "outside_sprite.png" + self.restricted_dir, "outside_sprite.png" ) - traversal_path = os.path.relpath(outside_sprite_path, self.log_dir) + traversal_path = "../restricted_dir/outside_sprite.png" self._WriteTextFile(outside_sprite_path, "not-an-image") self._GenerateProjectorAssetsTestData(sprite_image_path=traversal_path) self._SetupWSGIApp()