Перейти к содержимому
RootPoligon

Границы путей и объёма

Подготовка к этой главе

Нужны 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.

Каждая строка — отдельный способ выйти наружу или переполнить ответ.

Четыре выхода наружу

  1. Абсолютный путь. NOTES / "/home/you/mcp-lab/outside/training-secret.txt" — это просто второй путь: в pathlib соединение с абсолютным путём отбрасывает левую часть.
  2. ... "../../../outside/training-secret.txt" поднимается из notes/.
  3. Ссылка. link-outside.md лежит внутри notes/, имя безобидное, но ведёт наружу. Проверка имени на .. её не видит — нужно раскрыть ссылку: Path.resolve() возвращает настоящий путь.
  4. Похожее имя. Частая «правильная» проверка 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.

Самостоятельно

  1. Перепишите note_read так, чтобы все 11 проверок главы проходили:
    • аргумент — строка от 1 до 200 символов с описанием «путь заметки внутри notes/»;
    • сразу отказывайте абсолютным путям, обратной косой черте и любому .. среди частей пути;
    • раскройте путь (resolve()), проверьте is_relative_to(NOTES.resolve()) и что это обычный файл;
    • откройте файл с os.O_NOFOLLOW и прочитайте не больше MAX_NOTE + 1 байт, чтобы узнать, был ли он длиннее.
  2. Добавьте в свой клиент проверку: note_read("link-outside.md") даёт is_error=True, а в ответе нет текста «УЧЕБНЫЙ ФАЙЛ ВНЕ».
  3. Ответьте себе: проверка «раскрыть путь, потом открыть» оставляет короткое окно, в котором ссылку можно подменить. Что в вашей реализации его сужает? Почему для учебного сервера этого достаточно, а для общего сервера с чужими пользователями — нет?

Проверка

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.
  • Пакет mcp 2.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. Открывайте после своей попытки. Это решение учебной главы, не готовый сервер для рабочих данных.

Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.