fix(webui): validate inferred file paths before preview (#4935)

This commit is contained in:
chengyongru
2026-07-15 10:45:40 +08:00
committed by GitHub
parent aa70aa48f9
commit 5ed28a6744
10 changed files with 461 additions and 51 deletions
+45 -22
View File
@@ -30,28 +30,7 @@ def file_preview_payload(
) -> dict[str, Any]:
"""Return a text preview for a file allowed by the session workspace scope."""
path = _clean_preview_path(raw_path)
if not path:
raise WebUIFilePreviewError(400, "missing path")
if len(path) > 4096:
raise WebUIFilePreviewError(400, "path is too long")
try:
resolved = resolve_allowed_path(
path,
workspace=scope.project_path,
allowed_root=scope.project_path if scope.restrict_to_workspace else None,
strict=True,
)
except FileNotFoundError as e:
raise WebUIFilePreviewError(404, "file not found") from e
except WorkspaceBoundaryError as e:
raise WebUIFilePreviewError(403, "file is outside the current workspace") from e
except OSError as e:
raise WebUIFilePreviewError(400, "invalid path") from e
if not resolved.is_file():
raise WebUIFilePreviewError(404, "file not found")
resolved = _resolve_preview_path(raw_path, scope=scope)
try:
with open(resolved, "rb") as f:
@@ -81,6 +60,50 @@ def file_preview_payload(
}
def file_preview_availability_payload(
raw_path: str | None,
*,
scope: WorkspaceScope,
) -> dict[str, bool]:
"""Confirm that a path is a readable text preview candidate without loading it fully."""
resolved = _resolve_preview_path(raw_path, scope=scope)
try:
with open(resolved, "rb") as f:
prefix = f.read(4096)
except OSError as e:
raise WebUIFilePreviewError(500, "failed to read file") from e
if b"\0" in prefix:
raise WebUIFilePreviewError(415, "binary files cannot be previewed")
return {"available": True}
def _resolve_preview_path(raw_path: str | None, *, scope: WorkspaceScope) -> Path:
path = _clean_preview_path(raw_path)
if not path:
raise WebUIFilePreviewError(400, "missing path")
if len(path) > 4096:
raise WebUIFilePreviewError(400, "path is too long")
try:
resolved = resolve_allowed_path(
path,
workspace=scope.project_path,
allowed_root=scope.project_path if scope.restrict_to_workspace else None,
strict=True,
)
except FileNotFoundError as e:
raise WebUIFilePreviewError(404, "file not found") from e
except WorkspaceBoundaryError as e:
raise WebUIFilePreviewError(403, "file is outside the current workspace") from e
except OSError as e:
raise WebUIFilePreviewError(400, "invalid path") from e
if not resolved.is_file():
raise WebUIFilePreviewError(404, "file not found")
return resolved
def _clean_preview_path(raw_path: str | None) -> str:
if raw_path is None:
return ""
+15 -6
View File
@@ -29,7 +29,11 @@ from nanobot.cron.types import CronJob, CronSchedule
from nanobot.runtime_context import public_history_messages
from nanobot.triggers.local_types import LocalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.file_preview import (
WebUIFilePreviewError,
file_preview_availability_payload,
file_preview_payload,
)
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
from nanobot.webui.http_utils import (
case_insensitive_header as _case_insensitive_header,
@@ -494,13 +498,18 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
path = _query_first(_parse_query(request.path), "path")
query = _parse_query(request.path)
path = _query_first(query, "path")
is_probe = _query_first(query, "probe") == "1"
try:
payload = file_preview_payload(
path,
scope=self.workspaces.scope_for_session_key(decoded_key),
)
scope = self.workspaces.scope_for_session_key(decoded_key)
if is_probe:
payload = file_preview_availability_payload(path, scope=scope)
else:
payload = file_preview_payload(path, scope=scope)
except WebUIFilePreviewError as e:
if is_probe and e.status in {400, 403, 404, 415}:
return _http_json_response({"available": False})
return _http_error(e.status, e.message)
return _http_json_response(payload)