fix(webui): validate inferred file paths before preview (#4935)
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user