diff --git a/py/api/__init__.py b/py/api/__init__.py index 3948c905..64c2cad4 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -112,14 +112,15 @@ _STATIC_SEPARATORS = re.compile(r"[\\/]+") STATIC_EXACT_FILES = {("js", "inspection-semantics.json"): "application/json"} -def _resolve_static_file(root, filepath): +def _resolve_static_file(root, filepath, allowed=STATIC_MIME_TYPES): """Real path of ``filepath`` under ``root``, or None when it must not be served. The request path is untrusted. It is rejected when it is empty, carries a ``..`` segment, is absolute on any platform (a leading slash or backslash, a drive letter such as ``C:`` or a UNC ``\\\\server\\share`` prefix all re-anchor ``os.path.join`` on Windows), has an extension outside - STATIC_MIME_TYPES, or resolves (symlinks included) outside ``root``. + ``allowed`` (STATIC_MIME_TYPES), or resolves (symlinks included) outside + ``root``. """ if not isinstance(filepath, str) or not filepath: return None @@ -132,7 +133,7 @@ def _resolve_static_file(root, filepath): segments = _STATIC_SEPARATORS.split(filepath) if any(segment in ("", ".", "..") for segment in segments): return None - if os.path.splitext(filepath)[1].lower() not in STATIC_MIME_TYPES: + if os.path.splitext(filepath)[1].lower() not in allowed: return None try: root_real = Path(os.path.normcase(os.path.realpath(root))) @@ -482,13 +483,14 @@ class PromptManagerAPI( """Serve ``web//``; 403 outside it, 404 when missing.""" root = os.path.join(_get_project_root(), "web", subdir) exact = STATIC_EXACT_FILES.get((subdir, filepath)) - if exact is not None: - file_path, content_type = os.path.join(root, filepath), exact - else: - file_path = _resolve_static_file(root, filepath) - if file_path is None: - return web.Response(text="Forbidden", status=403) - content_type = STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()] + # An exact file passes the same containment check, its extension only. + allowed = STATIC_MIME_TYPES if exact is None else {".json"} + file_path = _resolve_static_file(root, filepath, allowed) + if file_path is None: + return web.Response(text="Forbidden", status=403) + content_type = ( + exact or STATIC_MIME_TYPES[os.path.splitext(file_path)[1].lower()] + ) if not os.path.isfile(file_path): return web.Response(text="Not Found", status=404) with open(file_path, "rb") as f: diff --git a/tests/test_api_core.py b/tests/test_api_core.py index a8e0ee87..4af56a96 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -508,7 +508,7 @@ class TestStaticRoutes(CoreRoutesTestCase): "js/INSPECTION-SEMANTICS.json", ): resp = await self.client.request("GET", f"/prompt_manager/{path}") - self.assertIn(resp.status, (403, 404), path) + self.assertEqual(resp.status, 403, path) async def test_lib_served(self): web_dir = self._fake_root() @@ -602,6 +602,28 @@ class TestStaticRoutes(CoreRoutesTestCase): resp = await self.client.request("GET", "/prompt_manager/lib/link.js") self.assertEqual(resp.status, 403) + async def test_inspection_semantics_symlink_escaping_web_dir_is_403(self): + """The exact-path JSON gets the same realpath containment as any file.""" + web_dir = self._fake_root() + secret = Path(self.tmpdir) / "secret.json" + secret.write_text('{"secret": true}') + try: + os.symlink(secret, web_dir / "js" / "inspection-semantics.json") + except (OSError, NotImplementedError): + self.skipTest("symlinks not available") + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 403) + self.assertNotIn("secret", await resp.text()) + + async def test_inspection_semantics_missing_is_404(self): + self._fake_root() + resp = await self.client.request( + "GET", "/prompt_manager/js/inspection-semantics.json" + ) + self.assertEqual(resp.status, 404) + async def test_empty_path_is_not_served(self): for prefix in ("lib", "js"): resp = await self.client.request("GET", f"/prompt_manager/{prefix}/")