fix(inspect): realpath containment for the exact-path node table route
inspection-semantics.json now goes through the same realpath check as every other static file, so a symlink out of web/ is refused. The forbidden-path test asserts exactly 403.
This commit is contained in:
+12
-10
@@ -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/<subdir>/<filepath>``; 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:
|
||||
|
||||
+23
-1
@@ -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}/")
|
||||
|
||||
Reference in New Issue
Block a user