From 9d8c4fafe4b9ddd47b9b0b04f16568fe74e59984 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 26 Oct 2026 17:48:29 -0700 Subject: [PATCH] fix(lora): never serve a LoraManager folder that holds the home folder (#355) The folder guard also rejects ancestors of the home folder (/home, C:\Users), compared in the realpath + normcase form, so a LoraManager setting cannot widen the image server's allow-list to every user's files. --- py/lora_manager_layout.py | 8 +++-- tests/test_lora_manager_layout.py | 53 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/py/lora_manager_layout.py b/py/lora_manager_layout.py index e937cce4..4e9867cc 100644 --- a/py/lora_manager_layout.py +++ b/py/lora_manager_layout.py @@ -161,15 +161,17 @@ def _guard_folder( ) -> Optional[str]: """The canonical form of ``path`` when it may be read and served, else None. - Kept only when it is a folder, not a filesystem root and not the home - folder itself. + Kept only when it is a folder, not a filesystem root, and neither the + home folder nor one of its ancestors (``/home``, ``C:\\Users``: a + security review ruled that a folder holding the home folder must never + be read or served). """ isdir = isdir or os.path.isdir try: canonical = _canonical(path, pathmod) if not isdir(canonical) or _is_filesystem_root(canonical, pathmod): return None - if canonical == _canonical(home, pathmod): + if _inside(home, (canonical,), pathmod): return None except (OSError, ValueError): return None diff --git a/tests/test_lora_manager_layout.py b/tests/test_lora_manager_layout.py index a6354cef..c753d076 100644 --- a/tests/test_lora_manager_layout.py +++ b/tests/test_lora_manager_layout.py @@ -688,6 +688,59 @@ class FolderGuardTests(unittest.TestCase): ) self.assertIsNone(layout_module._guard_folder(str(self.tmp), str(self.tmp))) + def test_ancestors_of_home_are_rejected(self): + # Security review (#355): a settings folder that contains the home + # folder would serve every user's files. + home = self.tmp / "home" / "me" + inside = home / "loras" + inside.mkdir(parents=True) + guard = layout_module._guard_folder + self.assertIsNone(guard(str(self.tmp / "home"), str(home))) + self.assertIsNone(guard(str(self.tmp), str(home))) + self.assertIsNone(guard(str(home), str(home))) + self.assertEqual(guard(str(inside), str(home)), os.path.normcase(str(inside))) + # A sibling sharing the name prefix is not an ancestor. + sibling = self.tmp / "home" / "me2" + sibling.mkdir() + self.assertEqual(guard(str(sibling), str(home)), os.path.normcase(str(sibling))) + + def test_windows_ancestors_of_home_are_rejected(self): + self.assertIsNone(self.guard_nt("C:\\Users", home="C:\\Users\\me")) + self.assertIsNone(self.guard_nt("c:/users/", home="C:\\Users\\me")) + self.assertEqual( + self.guard_nt("C:\\Users\\me\\Loras", home="C:\\Users\\me"), + "c:\\users\\me\\loras", + ) + self.assertEqual( + self.guard_nt("C:\\Users\\meta", home="C:\\Users\\me"), + "c:\\users\\meta", + ) + + def test_settings_folders_that_contain_home_are_not_served(self): + home = self.tmp / "home" / "me" + home.mkdir(parents=True) + settings = self.tmp / "settings" + settings.mkdir() + (settings / "settings.json").write_text( + json.dumps( + { + "folder_paths": {"loras": [str(self.tmp / "home")]}, + "example_images_path": str(self.tmp), + } + ) + ) + with patch.object(layout_module.Path, "home", return_value=home): + layout = load_layout( + str(self.tmp), + default_roots=lambda: [], + environ={"LORA_MANAGER_SETTINGS_DIR": str(settings)}, + user_config_dir=lambda: str(self.tmp / "unused"), + ) + self.assertIsNone(layout.error) + self.assertEqual(layout.lora_roots, ()) + self.assertIsNone(layout.example_images_root) + self.assertEqual(layout.media_roots, ()) + def test_missing_folders_add_nothing(self): self.assertIsNone(layout_module._guard_folder(str(self.tmp / "x"), "/home")) self.assertIsNone(