mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
fix(core): reject hardlinked files in restricted local fs tools
Reject multi-linked regular files in restricted local filesystem tools so workspace hardlink aliases cannot read or overwrite files outside allowed directories. Fixes #8868.
This commit is contained in:
@@ -34,6 +34,7 @@ Local path resolution rule:
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -182,6 +183,25 @@ def _is_path_within_allowed_roots(
|
||||
)
|
||||
|
||||
|
||||
def _reject_multi_link_file(path: str) -> None:
|
||||
try:
|
||||
path_stat = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise PermissionError(
|
||||
"Access denied: unable to inspect restricted path link count. "
|
||||
f"Blocked path: {path}."
|
||||
) from exc
|
||||
|
||||
if stat.S_ISREG(path_stat.st_mode) and path_stat.st_nlink > 1:
|
||||
raise PermissionError(
|
||||
"Access denied: file has multiple hard links and may alias content "
|
||||
"outside allowed directories. "
|
||||
f"Link count: {path_stat.st_nlink}. Blocked path: {path}."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rw_path(
|
||||
path: str,
|
||||
*,
|
||||
@@ -208,6 +228,8 @@ def _normalize_rw_path(
|
||||
f"{access} access is restricted for this user. "
|
||||
f"Allowed directories: {allowed}. Blocked path: {normalized_path}."
|
||||
)
|
||||
if restricted:
|
||||
_reject_multi_link_file(normalized_path)
|
||||
return normalized_path
|
||||
|
||||
|
||||
@@ -602,6 +624,8 @@ class GrepTool(FunctionTool):
|
||||
"Read access is restricted for this user. "
|
||||
f"Allowed directories: {allowed}. Blocked paths: {blocked}."
|
||||
)
|
||||
for path in normalized:
|
||||
_reject_multi_link_file(path)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -194,6 +195,13 @@ def _make_large_text() -> str:
|
||||
return "".join(f"line-{index:05d}-{'x' * 48}\n" for index in range(6000))
|
||||
|
||||
|
||||
def _make_hardlink_or_skip(source, link) -> None:
|
||||
try:
|
||||
os.link(source, link)
|
||||
except (AttributeError, OSError) as exc:
|
||||
pytest.skip(f"hard links are unavailable on this filesystem: {exc}")
|
||||
|
||||
|
||||
def _make_epub_bytes(*, chapter_count: int = 1) -> bytes:
|
||||
manifest_items = [
|
||||
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
|
||||
@@ -363,6 +371,36 @@ async def test_restricted_local_member_cannot_write_plugin_provided_skill(
|
||||
assert plugin_skill.read_text(encoding="utf-8") == "# Demo Skill\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_local_member_rejects_workspace_hardlink_alias(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
):
|
||||
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
outside_file = outside_dir / "secret.txt"
|
||||
outside_file.write_text("outside-secret\n", encoding="utf-8")
|
||||
hardlink_path = workspace / "linked.txt"
|
||||
_make_hardlink_or_skip(outside_file, hardlink_path)
|
||||
|
||||
read_result = await fs_tools.FileReadTool().call(
|
||||
_make_context(role="member"),
|
||||
path="linked.txt",
|
||||
)
|
||||
write_result = await fs_tools.FileWriteTool().call(
|
||||
_make_context(role="member"),
|
||||
path="linked.txt",
|
||||
content="changed\n",
|
||||
)
|
||||
|
||||
assert "multiple hard links" in read_result
|
||||
assert "may alias content outside allowed directories" in read_result
|
||||
assert "multiple hard links" in write_result
|
||||
assert "may alias content outside allowed directories" in write_result
|
||||
assert outside_file.read_text(encoding="utf-8") == "outside-secret\n"
|
||||
|
||||
|
||||
def test_detect_text_encoding_allows_utf8_probe_cut_mid_character():
|
||||
sample = '{"results": ["中文内容"]}'.encode()[:-1]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user