-
Notifications
You must be signed in to change notification settings - Fork 799
fix(web): handle PermissionError when listing directory entries #1692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -483,8 +483,12 @@ async def get_session_file( | |||||||||||||||||||
| requested_path = work_dir / path | ||||||||||||||||||||
| file_path = requested_path.resolve() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Check path traversal | ||||||||||||||||||||
| # Check path traversal — symlinks pointing outside work_dir are not an | ||||||||||||||||||||
| # attack, but we must not serve their content. For directories we return | ||||||||||||||||||||
| # an empty listing so the frontend BFS can continue; for files we 404. | ||||||||||||||||||||
| if not file_path.is_relative_to(work_dir): | ||||||||||||||||||||
| if requested_path.is_symlink(): | ||||||||||||||||||||
| return Response(content="[]", media_type="application/json") | ||||||||||||||||||||
|
Comment on lines
+490
to
+491
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This unconditional early return treats any symlink escaping Useful? React with 👍 / 👎.
Comment on lines
+490
to
+491
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Symlink to file outside work_dir returns The comment on lines 486-488 states: "For directories we return an empty listing so the frontend BFS can continue; for files we 404." However, the implementation at line 490-491 unconditionally returns
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||||
| status_code=status.HTTP_400_BAD_REQUEST, | ||||||||||||||||||||
| detail="Invalid path: path traversal not allowed", | ||||||||||||||||||||
|
|
@@ -522,21 +526,28 @@ async def get_session_file( | |||||||||||||||||||
|
|
||||||||||||||||||||
| if file_path.is_dir(): | ||||||||||||||||||||
| result: list[dict[str, str | int]] = [] | ||||||||||||||||||||
| for subpath in file_path.iterdir(): | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| entries = list(file_path.iterdir()) | ||||||||||||||||||||
| except PermissionError: | ||||||||||||||||||||
| entries = [] | ||||||||||||||||||||
| for subpath in entries: | ||||||||||||||||||||
| if restrict_sensitive_apis: | ||||||||||||||||||||
| rel_subpath = rel_path / subpath.name | ||||||||||||||||||||
| if _is_sensitive_relative_path(rel_subpath): | ||||||||||||||||||||
| continue | ||||||||||||||||||||
| if subpath.is_dir(): | ||||||||||||||||||||
| result.append({"name": subpath.name, "type": "directory"}) | ||||||||||||||||||||
| else: | ||||||||||||||||||||
| result.append( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| "name": subpath.name, | ||||||||||||||||||||
| "type": "file", | ||||||||||||||||||||
| "size": subpath.stat().st_size, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| if subpath.is_dir(): | ||||||||||||||||||||
| result.append({"name": subpath.name, "type": "directory"}) | ||||||||||||||||||||
| else: | ||||||||||||||||||||
| result.append( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| "name": subpath.name, | ||||||||||||||||||||
| "type": "file", | ||||||||||||||||||||
| "size": subpath.stat().st_size, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| except (PermissionError, OSError): | ||||||||||||||||||||
| continue | ||||||||||||||||||||
| result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"]))) | ||||||||||||||||||||
| return Response(content=json.dumps(result), media_type="application/json") | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """Tests for file listing in sessions API — PermissionError and symlink handling.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
| from starlette.testclient import TestClient | ||
|
|
||
| from kimi_cli.web.app import create_app | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(): | ||
| app = create_app(lan_only=False) | ||
| with TestClient(app) as c: | ||
| yield c | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def session_with_work_dir(client: TestClient, tmp_path: Path): | ||
| """Create a session pointing to tmp_path as work_dir.""" | ||
| resp = client.post("/api/sessions/", json={"work_dir": str(tmp_path)}) | ||
| assert resp.status_code == 200 | ||
| return resp.json()["session_id"], tmp_path | ||
|
|
||
|
|
||
| def test_list_dir_skips_permission_error_on_iterdir( | ||
| client: TestClient, session_with_work_dir: tuple[str, Path] | ||
| ): | ||
| """iterdir() raising PermissionError (e.g. macOS .Trash) should not crash.""" | ||
| session_id, work_dir = session_with_work_dir | ||
|
|
||
| # Create a normal file so we can verify endpoint works | ||
| (work_dir / "hello.txt").write_text("hi") | ||
|
|
||
| original_iterdir = Path.iterdir | ||
|
|
||
| def patched_iterdir(self: Path): | ||
| if self == work_dir: | ||
| raise PermissionError("Operation not permitted") | ||
| return original_iterdir(self) | ||
|
|
||
| with patch.object(Path, "iterdir", patched_iterdir): | ||
| resp = client.get(f"/api/sessions/{session_id}/files/") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| # iterdir failed on the directory, so result should be empty | ||
| assert data == [] | ||
|
|
||
|
|
||
| def test_list_dir_skips_individual_stat_error( | ||
| client: TestClient, session_with_work_dir: tuple[str, Path] | ||
| ): | ||
| """Individual entry stat() failure should be skipped, not crash.""" | ||
| session_id, work_dir = session_with_work_dir | ||
|
|
||
| (work_dir / "good.txt").write_text("ok") | ||
| (work_dir / "bad_entry").mkdir() | ||
|
|
||
| original_is_dir = Path.is_dir | ||
|
|
||
| def patched_is_dir(self: Path): | ||
| if self.name == "bad_entry": | ||
| raise PermissionError("Operation not permitted") | ||
| return original_is_dir(self) | ||
|
|
||
| with patch.object(Path, "is_dir", patched_is_dir): | ||
| resp = client.get(f"/api/sessions/{session_id}/files/") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| names = [e["name"] for e in data] | ||
| assert "good.txt" in names | ||
| assert "bad_entry" not in names | ||
|
|
||
|
|
||
| def test_symlink_outside_work_dir_returns_empty_listing(client: TestClient, tmp_path: Path): | ||
| """Symlink pointing outside work_dir should return empty JSON list, not 400.""" | ||
| # Use a subdirectory as work_dir so the external dir is truly outside | ||
| work_dir = tmp_path / "workspace" | ||
| work_dir.mkdir() | ||
| resp = client.post("/api/sessions/", json={"work_dir": str(work_dir)}) | ||
| assert resp.status_code == 200 | ||
| session_id = resp.json()["session_id"] | ||
|
|
||
| # Create an external directory and a symlink inside work_dir pointing to it | ||
| external_dir = tmp_path / "external" | ||
| external_dir.mkdir() | ||
| (external_dir / "secret.txt").write_text("secret") | ||
| (work_dir / "linked").symlink_to(external_dir) | ||
|
|
||
| # Also create a normal file to ensure the rest of the listing is unaffected | ||
| (work_dir / "normal.txt").write_text("ok") | ||
|
|
||
| # Accessing the symlink directory should return an empty listing | ||
| resp = client.get(f"/api/sessions/{session_id}/files/linked") | ||
| assert resp.status_code == 200 | ||
| assert resp.json() == [] | ||
|
|
||
| # The root listing should still include the symlink entry and the normal file | ||
| resp = client.get(f"/api/sessions/{session_id}/files/") | ||
| assert resp.status_code == 200 | ||
| names = [e["name"] for e in resp.json()] | ||
| assert "normal.txt" in names | ||
| assert "linked" in names |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,7 +143,13 @@ const crawlWorkspace = async ({ | |
|
|
||
| // "." should be treated as undefined for API root | ||
| const path = current === "." ? undefined : current; | ||
| const entries = await listDirectory(sessionId, path); | ||
| let entries: SessionFileEntry[]; | ||
| try { | ||
| entries = await listDirectory(sessionId, path); | ||
| } catch { | ||
| // Skip directories that fail (e.g. symlinks outside work_dir) | ||
| continue; | ||
|
Comment on lines
+147
to
+151
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Catching every Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| for (const entry of entries) { | ||
| const fullPath = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Manually edited auto-generated English changelog violating repo rules
docs/en/release-notes/changelog.mdis explicitly documented as auto-generated from the rootCHANGELOG.mdvia the sync script (docs/scripts/sync-changelog.mjs). Thedocs/AGENTS.md:185rule states: "The English changelog (docs/en/release-notes/changelog.md) is auto-generated from the rootCHANGELOG.md. Do not edit it manually." This PR manually edits the file, which will be overwritten the next time the sync script runs (duringnpm run devornpm run build). The rootCHANGELOG.mdis already updated with the same entry, so the correct workflow is to only edit the root file and let the sync script propagate the change.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.