Границы путей и объёма
Подготовка к этой главе
Нужны Linux, Python 3.12 и активное окружение из первого сервера.
Продолжайте свой diag_server.py после предыдущей главы. Если начинаете здесь,
скачайте исходное состояние — стадия 3,
распакуйте в новую папку и создайте данные через python3 make_lab.py.
Не перезаписывайте свою работу: сначала сохраните её отдельно.
Ситуация
Агенту нужен инструмент «прочитать заметку». Пользователь пишет: «посмотри заметку link-outside.md». Модель вызывает инструмент с этим именем. Что прочитает сервер — зависит только от кода сервера.
В учебной папке специально созданы ловушки (все файлы учебные):
~/mcp-lab/
├── outside/training-secret.txt ← «вне разрешённой папки»
└── app/data/
├── notes/ ← разрешённая папка
│ ├── welcome.md, shopping.md, big.md (≈330 КБ), archive/2025.md
│ ├── link-inside.md → welcome.md
│ ├── link-outside.md → ../../../outside/training-secret.txt
│ └── link-sibling.md → ../notes-private/keys.md
└── notes-private/keys.md ← соседняя папка с похожим именем
Главная мысль
Граница — это проверка итогового пути после раскрытия ссылок, сравнение по частям пути, а не по строке, и предел объёма при чтении. Всё это — в коде сервера. Ни модель, ни описание инструмента границу не держат.
Наивная версия
Добавьте в сервер модель и инструмент:
class Note(BaseModel):
name: str
size: int
text: str
truncated: bool
@mcp.tool()
def note_read(name: str) -> Note:
"""Читает заметку из папки заметок."""
path = NOTES / name
text = path.read_text(encoding="utf-8")
return Note(name=name, size=len(text.encode()), text=text, truncated=False)
Запустите только проверки этой главы:
python check.py --chapter 4 --only diag_server.py
✗ ссылка наружу отклонён(а), содержимое не выдано
✗ ссылка в соседнюю notes-private отклонён(а), содержимое не выдано
✗ путь с .. отклонён(а), содержимое не выдано
✗ абсолютный путь отклонён(а), содержимое не выдано
✗ большая заметка обрезана до 64 КиБ и помечена truncated
Итог: выполнено 11 из 11 проверок, пройдено 6.
Каждая строка — отдельный способ выйти наружу или переполнить ответ.
Четыре выхода наружу
- Абсолютный путь.
NOTES / "/home/you/mcp-lab/outside/training-secret.txt"— это просто второй путь: вpathlibсоединение с абсолютным путём отбрасывает левую часть. ..."../../../outside/training-secret.txt"поднимается изnotes/.- Ссылка.
link-outside.mdлежит внутриnotes/, имя безобидное, но ведёт наружу. Проверка имени на..её не видит — нужно раскрыть ссылку:Path.resolve()возвращает настоящий путь. - Похожее имя. Частая «правильная» проверка
str(target).startswith(str(base))пропускает…/data/notes-private/keys.md: строка начинается с…/data/notes. Сравнивайте по частям пути:target.is_relative_to(base).
Объём и сообщения
- Читайте не больше предела (
MAX_NOTE = 64 * 1024байт) и говорите, что обрезали:truncated: true. Срез по байтам может разрезать последнюю букву UTF-8 пополам — уберите получившийся символ замены�в конце. - Сообщение об отказе не должно раскрывать абсолютные пути сервера: «путь ведёт за пределы notes/» достаточно. Модель перескажет сообщение пользователю или другому инструменту.
- Отказ — это
ToolError(from mcp.server.mcpserver.exceptions import ToolError): клиент получитis_error=Trueи ваш текст. Подробнее об ошибках — в главе 5.
Самостоятельно
- Перепишите
note_readтак, чтобы все 11 проверок главы проходили:- аргумент — строка от 1 до 200 символов с описанием «путь заметки внутри notes/»;
- сразу отказывайте абсолютным путям, обратной косой черте и любому
..среди частей пути; - раскройте путь (
resolve()), проверьтеis_relative_to(NOTES.resolve())и что это обычный файл; - откройте файл с
os.O_NOFOLLOWи прочитайте не большеMAX_NOTE + 1байт, чтобы узнать, был ли он длиннее.
- Добавьте в свой клиент проверку:
note_read("link-outside.md")даётis_error=True, а в ответе нет текста «УЧЕБНЫЙ ФАЙЛ ВНЕ». - Ответьте себе: проверка «раскрыть путь, потом открыть» оставляет короткое окно, в котором ссылку можно подменить. Что в вашей реализации его сужает? Почему для учебного сервера этого достаточно, а для общего сервера с чужими пользователями — нет?
Проверка
python check.py --chapter 4 diag_server.py
Ожидается Итог: выполнено 24 из 24 проверок, пройдено 24. (главы 2–4).
Если результат другой
- Проходит всё, кроме «соседней notes-private», — у вас сравнение строк вместо
is_relative_to. - Проходит всё, кроме «ссылки наружу», — нет
resolve(). - «Сообщение об отказе не раскрывает абсолютный путь» не проходит — в тексте
ToolErrorестьtargetилиNOTES. - «Большая заметка» не проходит, хотя
truncatedесть, — длина текста в байтах больше 64 КиБ из-за�в конце.
Решение — answers/04-boundaries.md; полный файл — project/stages/04/diag_server.py.
Источники
- Документация Python:
pathlib.Path.resolve,Path.is_relative_to,os.openсO_NOFOLLOW. - Пакет
mcp2.3.0:ToolError— ожидаемая ошибка инструмента, клиент получаетis_error=Trueи текст (mcp/server/mcpserver/exceptions.py).
Разбор и проверка понимания
Полный файл — project/stages/04/diag_server.py.
MAX_NOTE = 64 * 1024 # байт заметки в ответе
@mcp.tool()
def note_read(name: Annotated[str, Field(min_length=1, max_length=200, description="путь заметки внутри notes/")]) -> Note:
"""Читает заметку из папки заметок. Пути наружу и ссылки за её пределы отклоняются."""
if name.startswith("/") or "\\" in name or ".." in Path(name).parts:
raise ToolError("допустимы только относительные пути внутри notes/ без '..'")
base = NOTES.resolve()
target = (NOTES / name).resolve() # раскрывает и ссылки
if not target.is_relative_to(base):
raise ToolError("путь ведёт за пределы notes/") # без абсолютного пути в сообщении
if not target.is_file():
raise ToolError(f"заметка не найдена: {name}")
fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "rb") as f:
size = os.fstat(f.fileno()).st_size
raw = f.read(MAX_NOTE + 1)
truncated = len(raw) > MAX_NOTE
text = raw[:MAX_NOTE].decode("utf-8", errors="replace")
if truncated:
text = text.rstrip("�") # срез мог разрезать последнюю букву пополам
return Note(name=name, size=size, text=text, truncated=truncated)
Клиент:
r = await client.call_tool("note_read", {"name": "link-outside.md"})
leaked = "УЧЕБНЫЙ ФАЙЛ ВНЕ" in (r.content[0].text if r.content else "")
print("OK" if r.is_error and not leaked else "FAIL", "ссылка наружу отклонена")
Вопрос 3. Между resolve() и os.open ссылку можно подменить. O_NOFOLLOW не даёт открыть итоговый файл, если
он сам стал ссылкой, но не защищает от подмены каталога выше. Для учебной папки, куда пишете только вы, этого
достаточно. Общему серверу с чужими пользователями нужен обход пути от открытого дескриптора разрешённой папки
(openat по частям с O_NOFOLLOW) или изоляция процесса: отдельный пользователь, контейнер, только нужная папка.
Три частые ошибки и что показывает проверка (воспроизведено, см. журнал):
| Ошибка | Не проходят |
|---|---|
| Наивная версия из главы | ссылка наружу, соседняя папка, .., абсолютный путь, большая заметка (6 из 11) |
Проверка только на / в начале и .. | ссылка наружу, соседняя папка, большая заметка (8 из 11) |
resolve() + startswith, путь в сообщении | соседняя папка, сообщение раскрывает путь, большая заметка (8 из 11) |
Проверка курса: python check.py --chapter 4 diag_server.py → Итог: выполнено 24 из 24 проверок, пройдено 24.
Полный файл после решения
Эталон стадии 4. Открывайте после своей попытки. Это решение учебной главы, не готовый сервер для рабочих данных.
Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.