Skip to content

Commit deb522a

Browse files
authored
Fix Projector Plugin vulnerability (#7115)
## Summary Fixes an arbitrary file read issue in the TensorBoard Projector plugin by restricting asset paths to the directory that contains `projector_config.pbtxt`. Previously, user-controlled fields such as `metadata_path`, `tensor_path`, `bookmarks_path`, and `sprite.image_path` could resolve to absolute paths or traversal paths outside the intended logdir/config directory. That allowed a malicious config to make TensorBoard read and return arbitrary local files from the host. ## What Changed - Hardened projector asset path resolution to: - expand and normalize candidate paths - resolve them against the directory containing `projector_config.pbtxt` - reject any path that escapes that directory boundary - Returned a clean `400` response when a requested asset path is invalid - Applied this validation consistently across: - metadata loading - tensor loading - bookmarks loading - sprite image loading - Updated config augmentation logic to safely skip invalid external tensor paths instead of trying to read them ## Security Impact This closes a path traversal / arbitrary local file read vector in the Projector plugin for deployments where an attacker can write or influence `projector_config.pbtxt` contents under a scanned logdir. ## Tests Added projector integration coverage for: - `metadata_path` using traversal outside the logdir - `tensor_path` using an absolute path outside the logdir - `bookmarks_path` using an absolute path outside the logdir - `sprite.image_path` using traversal outside the logdir ## Validation Verified: - `python -m py_compile tensorboard/plugins/projector/projector_plugin.py tensorboard/plugins/projector/projector_plugin_test.py` - `bazel test //tensorboard/plugins/projector:projector_plugin_test` - Full build and test suite ## Risk / Compatibility Low risk for valid configurations. This change may reject projector configs that previously referenced assets outside the config directory, but that behavior is now considered unsafe and is intentionally blocked.
1 parent 2331d66 commit deb522a

2 files changed

Lines changed: 181 additions & 14 deletions

File tree

tensorboard/plugins/projector/projector_plugin.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,23 @@ def _parse_positive_int_param(request, param_name):
210210

211211

212212
def _rel_to_abs_asset_path(fpath, config_fpath):
213-
fpath = os.path.expanduser(fpath)
214-
if not os.path.isabs(fpath):
215-
return os.path.join(os.path.dirname(config_fpath), fpath)
216-
return fpath
213+
config_dir = os.path.realpath(
214+
os.path.dirname(os.path.expanduser(config_fpath))
215+
)
216+
candidate = os.path.expanduser(fpath)
217+
if not os.path.isabs(candidate):
218+
candidate = os.path.join(config_dir, candidate)
219+
candidate = os.path.realpath(candidate)
220+
error_message = 'Asset path "%s" resolves outside the config directory' % (
221+
fpath
222+
)
223+
try:
224+
common_path = os.path.commonpath([config_dir, candidate])
225+
except ValueError as e:
226+
raise ValueError(error_message) from e
227+
if common_path != config_dir:
228+
raise ValueError(error_message)
229+
return candidate
217230

218231

219232
def _using_tf():
@@ -363,9 +376,18 @@ def _augment_configs_with_checkpoint_info(self):
363376
embedding.tensor_name = embedding.tensor_name[:-2]
364377
# Find the size of embeddings associated with a tensors file.
365378
if embedding.tensor_path:
366-
fpath = _rel_to_abs_asset_path(
367-
embedding.tensor_path, self.config_fpaths[run]
368-
)
379+
try:
380+
fpath = _rel_to_abs_asset_path(
381+
embedding.tensor_path, self.config_fpaths[run]
382+
)
383+
except ValueError as e:
384+
logger.warning(
385+
'Skipping tensor path "%s" for run "%s": %s',
386+
embedding.tensor_path,
387+
run,
388+
e,
389+
)
390+
continue
369391
tensor = self.tensor_cache.get((run, embedding.tensor_name))
370392
if tensor is None:
371393
try:
@@ -594,7 +616,10 @@ def _serve_metadata(self, request):
594616
"text/plain",
595617
400,
596618
)
597-
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
619+
try:
620+
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
621+
except ValueError as e:
622+
return Respond(request, str(e), "text/plain", 400)
598623
if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath):
599624
return Respond(
600625
request,
@@ -651,9 +676,12 @@ def _serve_tensor(self, request):
651676
embedding = self._get_embedding(name, config)
652677

653678
if embedding and embedding.tensor_path:
654-
fpath = _rel_to_abs_asset_path(
655-
embedding.tensor_path, self.config_fpaths[run]
656-
)
679+
try:
680+
fpath = _rel_to_abs_asset_path(
681+
embedding.tensor_path, self.config_fpaths[run]
682+
)
683+
except ValueError as e:
684+
return Respond(request, str(e), "text/plain", 400)
657685
if not tf.io.gfile.exists(fpath):
658686
return Respond(
659687
request,
@@ -720,7 +748,10 @@ def _serve_bookmarks(self, request):
720748
"text/plain",
721749
400,
722750
)
723-
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
751+
try:
752+
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
753+
except ValueError as e:
754+
return Respond(request, str(e), "text/plain", 400)
724755
if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath):
725756
return Respond(
726757
request,
@@ -766,7 +797,10 @@ def _serve_sprite_image(self, request):
766797
)
767798

768799
fpath = os.path.expanduser(embedding_info.sprite.image_path)
769-
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
800+
try:
801+
fpath = _rel_to_abs_asset_path(fpath, self.config_fpaths[run])
802+
except ValueError as e:
803+
return Respond(request, str(e), "text/plain", 400)
770804
if not tf.io.gfile.exists(fpath) or tf.io.gfile.isdir(fpath):
771805
return Respond(
772806
request,

tensorboard/plugins/projector/projector_plugin_test.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ def __init__(self, *args, **kwargs):
5555
self.server = None
5656

5757
def setUp(self):
58-
self.log_dir = self.get_temp_dir()
58+
self.test_dir = self.get_temp_dir()
59+
self.log_dir = os.path.join(self.test_dir, "log_dir")
60+
self.restricted_dir = os.path.join(self.test_dir, "restricted_dir")
61+
tf.io.gfile.makedirs(self.log_dir)
62+
tf.io.gfile.makedirs(self.restricted_dir)
5963

6064
def testRunsWithValidCheckpoint(self):
6165
self._GenerateProjectorTestData()
@@ -197,6 +201,93 @@ def testBookmarks(self):
197201
bookmark = self._GetJson(url)
198202
self.assertEqual(bookmark, {"a": "b"})
199203

204+
def testMetadataServesRelativeFileWithinLogdir(self):
205+
self._GenerateProjectorAssetsTestData(metadata_path="metadata.tsv")
206+
self._WriteTextFile(
207+
os.path.join(self.log_dir, "metadata.tsv"), "label\nvalue\n"
208+
)
209+
self._SetupWSGIApp()
210+
211+
response = self._Get(
212+
"/data/plugin/projector/metadata?run=.&name=embedding"
213+
)
214+
self.assertEqual(response.status_code, 200)
215+
self.assertEqual(response.data, b"label\nvalue\n")
216+
217+
def testMetadataRejectsTraversalOutsideLogdir(self):
218+
outside_metadata_path = os.path.join(
219+
self.restricted_dir, "outside_metadata.tsv"
220+
)
221+
traversal_path = "../restricted_dir/outside_metadata.tsv"
222+
self._WriteTextFile(outside_metadata_path, "secret\n")
223+
self._GenerateProjectorAssetsTestData(metadata_path=traversal_path)
224+
self._SetupWSGIApp()
225+
226+
response = self._Get(
227+
"/data/plugin/projector/metadata?run=.&name=embedding"
228+
)
229+
self._AssertOutsideConfigDirResponse(response)
230+
231+
def testMetadataRejectsSymlinkOutsideLogdir(self):
232+
outside_metadata_path = os.path.join(
233+
self.restricted_dir, "outside_metadata.tsv"
234+
)
235+
symlink_path = os.path.join(self.log_dir, "metadata-link.tsv")
236+
self._WriteTextFile(outside_metadata_path, "secret\n")
237+
try:
238+
os.symlink(outside_metadata_path, symlink_path)
239+
except (AttributeError, NotImplementedError, OSError) as e:
240+
self.skipTest("symlinks unavailable: %s" % e)
241+
self._GenerateProjectorAssetsTestData(metadata_path="metadata-link.tsv")
242+
self._SetupWSGIApp()
243+
244+
response = self._Get(
245+
"/data/plugin/projector/metadata?run=.&name=embedding"
246+
)
247+
self._AssertOutsideConfigDirResponse(response)
248+
249+
def testTensorRejectsAbsolutePathOutsideLogdir(self):
250+
outside_tensor_path = os.path.join(
251+
self.restricted_dir, "outside_tensor.tsv"
252+
)
253+
self._WriteTextFile(outside_tensor_path, "1.0\t2.0\n")
254+
self._GenerateProjectorAssetsTestData(tensor_path=outside_tensor_path)
255+
self._SetupWSGIApp()
256+
257+
response = self._Get(
258+
"/data/plugin/projector/tensor?run=.&name=embedding"
259+
)
260+
self._AssertOutsideConfigDirResponse(response)
261+
262+
def testBookmarksRejectAbsolutePathOutsideLogdir(self):
263+
outside_bookmarks_path = os.path.join(
264+
self.restricted_dir, "outside_bookmarks.json"
265+
)
266+
self._WriteTextFile(outside_bookmarks_path, '{"label": "secret"}')
267+
self._GenerateProjectorAssetsTestData(
268+
bookmarks_path=outside_bookmarks_path
269+
)
270+
self._SetupWSGIApp()
271+
272+
response = self._Get(
273+
"/data/plugin/projector/bookmarks?run=.&name=embedding"
274+
)
275+
self._AssertOutsideConfigDirResponse(response)
276+
277+
def testSpriteImageRejectsTraversalOutsideLogdir(self):
278+
outside_sprite_path = os.path.join(
279+
self.restricted_dir, "outside_sprite.png"
280+
)
281+
traversal_path = "../restricted_dir/outside_sprite.png"
282+
self._WriteTextFile(outside_sprite_path, "not-an-image")
283+
self._GenerateProjectorAssetsTestData(sprite_image_path=traversal_path)
284+
self._SetupWSGIApp()
285+
286+
response = self._Get(
287+
"/data/plugin/projector/sprite_image?run=.&name=embedding"
288+
)
289+
self._AssertOutsideConfigDirResponse(response)
290+
200291
def testEndpointsNoAssets(self):
201292
g = tf.Graph()
202293

@@ -213,6 +304,10 @@ def _AssertTensorResponse(self, tensor_bytes, expected_tensor):
213304
)
214305
self.assertTrue(np.array_equal(tensor, expected_tensor))
215306

307+
def _AssertOutsideConfigDirResponse(self, response):
308+
self.assertEqual(response.status_code, 400)
309+
self.assertIn(b"resolves outside the config directory", response.data)
310+
216311
# TODO(#2007): Cleanly separate out projector tests that require real TF
217312
@unittest.skipUnless(USING_REAL_TF, "Test only passes when using real TF")
218313
def testPluginIsActive(self):
@@ -336,6 +431,44 @@ def _GenerateProjectorTestData(self):
336431
)
337432
saver.save(sess, checkpoint_path)
338433

434+
def _GenerateProjectorAssetsTestData(
435+
self,
436+
tensor_path="tensor.tsv",
437+
metadata_path=None,
438+
bookmarks_path=None,
439+
sprite_image_path=None,
440+
):
441+
self._WriteTextFile(self._ResolveAssetPath(tensor_path), "1.0\t2.0\n")
442+
443+
config = projector_config_pb2.ProjectorConfig()
444+
embedding = config.embeddings.add()
445+
embedding.tensor_name = "embedding"
446+
embedding.tensor_path = tensor_path
447+
if metadata_path is not None:
448+
embedding.metadata_path = metadata_path
449+
if bookmarks_path is not None:
450+
embedding.bookmarks_path = bookmarks_path
451+
if sprite_image_path is not None:
452+
embedding.sprite.image_path = sprite_image_path
453+
454+
with tf.io.gfile.GFile(
455+
os.path.join(self.log_dir, "projector_config.pbtxt"), "w"
456+
) as f:
457+
f.write(text_format.MessageToString(config))
458+
459+
def _ResolveAssetPath(self, path):
460+
path = os.path.expanduser(path)
461+
if os.path.isabs(path):
462+
return os.path.realpath(path)
463+
return os.path.realpath(os.path.join(self.log_dir, path))
464+
465+
def _WriteTextFile(self, path, contents):
466+
parent = os.path.dirname(path)
467+
if parent:
468+
tf.io.gfile.makedirs(parent)
469+
with tf.io.gfile.GFile(path, "w") as f:
470+
f.write(contents)
471+
339472

340473
class MetadataColumnsTest(tf.test.TestCase):
341474
def testLengthDoesNotMatch(self):

0 commit comments

Comments
 (0)