feat(#243): in-RAM blob sandbox (anonymous GET by UUID, TTL, ETag) + stash_page tool with image mirroring #250

Merged
vvzvlad merged 5 commits from feat/243-blob-sandbox into develop 2026-06-28 21:01:14 +03:00

Фича #243 — in-RAM blob sandbox для передачи контента агентом

Канал «байты мимо контекста модели И мимо авторизации Docmost»: gitmost держит блоб в RAM, отдаёт анонимным HTTP GET по непрогнозируемому UUID с TTL 1ч. Через контекст агента летит только короткая ссылка. Полностью абстрактно — ни слова про конкретного потребителя (grep /habr/i по диффу = 0 совпадений).

Компоненты

  • apps/server/src/integrations/sandbox/sandbox.store.ts@Injectable singleton: in-RAM Map<uuid,{buf,mime,sha256,expiresAt}>. put(buf,mime)→{id=randomUUID, sha256, size, expiresAt}, get(id) (lazy expiry). Per-blob cap по mime, total-RAM guard с FIFO-эвикцией, unref'd 60s sweep (clear в onModuleDestroy). sha256 при put = ETag.
  • sandbox.controller.tsGET /api/sb/:id: UUID-regex анти-traversal→404, missing/expired→404, 200 с Content-Type/Length/ETag:"<sha256>"/Cache-Control: private,max-age,immutable, 304 на If-None-Match.
  • sandbox.module.ts@Global, единый инстанс store для контроллера + tool'ов.
  • Tool stash_page(MCP)/stashPage(in-app), вход {pageId}: сериализует page-json, зеркалит ВСЕ внутренние картинки (fetch по authed loopback → put → переписывает attrs.src на sandbox-URL), возвращает короткий uri/resource_link. Спека в общий SHARED_TOOL_SPECS.

Анонимный роут

JwtAuthGuard навешан per-controller (нет global APP_GUARD) → отсутствие guard'а делает роут анонимным (как /api/files/public); /api/sb добавлен в excludedPaths глобального workspace-preHandler. Капабилити = непрогнозируемый UUID + TTL + TLS, токенов нет.

Env

SANDBOX_PUBLIC_URL / SANDBOX_TTL_MS(1ч) / SANDBOX_MAX_BYTES(8MiB) / SANDBOX_MAX_IMAGE_BYTES(20MiB) / SANDBOX_MAX_TOTAL_BYTES(128MiB) — getters + validation + .env.example. uri собирается в wiring-слое, store/пакет env не трогают.

Совместимость с потребителем

Сверено point-by-point против контракта vvzvlad/habr-mcp (склонирован, прочитан): анонимный GET без Authorization ✓; doc как application/json ✓; ETag = quoted lowercase sha256 над отданными байтами, потребитель сверяет sha256 ✓; image discovery по attrs.src (plain string) ✓; image fetch + ETag + Content-Type ✓; resource_link uri ✓; TTL ~1ч ✓. Без протаскивания специфики потребителя в gitmost.

Тесты

mcp 370/370 (вкл. stash-page mock: mirror+rewrite, external CDN не трогается, dedup, failed-image без аборта, not-configured guard); server SandboxStore 9 + SandboxController 6 + ai-chat-tools/env зелёные; server tsc + nest build чисто.

Closes #243

🤖 Generated with Claude Code

## Фича #243 — in-RAM blob sandbox для передачи контента агентом Канал «байты мимо контекста модели И мимо авторизации Docmost»: gitmost держит блоб в RAM, отдаёт анонимным HTTP GET по непрогнозируемому UUID с TTL 1ч. Через контекст агента летит только короткая ссылка. **Полностью абстрактно** — ни слова про конкретного потребителя (grep `/habr/i` по диффу = 0 совпадений). ### Компоненты - `apps/server/src/integrations/sandbox/sandbox.store.ts` — `@Injectable` singleton: in-RAM `Map<uuid,{buf,mime,sha256,expiresAt}>`. `put(buf,mime)→{id=randomUUID, sha256, size, expiresAt}`, `get(id)` (lazy expiry). Per-blob cap по mime, total-RAM guard с FIFO-эвикцией, unref'd 60s sweep (clear в onModuleDestroy). sha256 при put = ETag. - `sandbox.controller.ts` — `GET /api/sb/:id`: UUID-regex анти-traversal→404, missing/expired→404, 200 с Content-Type/Length/`ETag:"<sha256>"`/`Cache-Control: private,max-age,immutable`, 304 на If-None-Match. - `sandbox.module.ts` — `@Global`, единый инстанс store для контроллера + tool'ов. - Tool `stash_page`(MCP)/`stashPage`(in-app), вход `{pageId}`: сериализует page-json, зеркалит ВСЕ внутренние картинки (fetch по authed loopback → put → переписывает `attrs.src` на sandbox-URL), возвращает короткий uri/`resource_link`. Спека в общий `SHARED_TOOL_SPECS`. ### Анонимный роут `JwtAuthGuard` навешан per-controller (нет global APP_GUARD) → отсутствие guard'а делает роут анонимным (как `/api/files/public`); `/api/sb` добавлен в `excludedPaths` глобального workspace-preHandler. Капабилити = непрогнозируемый UUID + TTL + TLS, токенов нет. ### Env `SANDBOX_PUBLIC_URL` / `SANDBOX_TTL_MS`(1ч) / `SANDBOX_MAX_BYTES`(8MiB) / `SANDBOX_MAX_IMAGE_BYTES`(20MiB) / `SANDBOX_MAX_TOTAL_BYTES`(128MiB) — getters + validation + .env.example. uri собирается в wiring-слое, store/пакет env не трогают. ### Совместимость с потребителем Сверено point-by-point против контракта `vvzvlad/habr-mcp` (склонирован, прочитан): анонимный GET без Authorization ✓; doc как application/json ✓; ETag = quoted lowercase sha256 над отданными байтами, потребитель сверяет sha256 ✓; image discovery по `attrs.src` (plain string) ✓; image fetch + ETag + Content-Type ✓; `resource_link` uri ✓; TTL ~1ч ✓. Без протаскивания специфики потребителя в gitmost. ### Тесты mcp 370/370 (вкл. stash-page mock: mirror+rewrite, external CDN не трогается, dedup, failed-image без аборта, not-configured guard); server SandboxStore 9 + SandboxController 6 + ai-chat-tools/env зелёные; server tsc + nest build чисто. Closes #243 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-28 15:15:07 +03:00
Add an ephemeral, process-local blob store so the in-app agent (and the
embedded MCP) can hand a large page document and its images to an external
consumer WITHOUT routing the bytes through the model context or Docmost auth.

- SandboxStore (@Injectable singleton): Map<uuid,{buf,mime,sha256,expiresAt}>
  in RAM only. put() picks a per-blob cap by mime (image vs doc), enforces a
  total-bytes RAM guard with oldest-first eviction, and stamps a TTL; get()
  lazily expires. sha256 computed at put() doubles as the strong ETag. An
  unref'd sweep interval clears expired entries and is cleared on destroy.
- GET /api/sb/:uuid anonymous controller: serves raw bytes with Content-Type,
  Content-Length and ETag=sha256; 404 on missing/expired/non-UUID (anti-
  traversal), 304 on a matching If-None-Match. No tokens, no 401 — the
  capability is the unguessable UUID + short TTL + TLS. Auth-exempt the same
  way as /api/files/public (no JwtAuthGuard) plus an /api/sb entry in main.ts's
  workspace-resolution preHandler so a remote consumer with no workspace host
  is not rejected.
- stash_page tool in both layers (MCP resource_link + in-app {uri,size,sha256,
  images}). client.stashPage serializes the get_page_json shape, mirrors every
  INTERNAL file/image src (type-agnostic, covers drawio/excalidraw/video/file)
  into the sandbox under Docmost auth and rewrites src to the sandbox URL;
  external http(s) srcs are left untouched; dedup by src; a failed image fetch
  is counted, never aborts the doc.
- SANDBOX_PUBLIC_URL / SANDBOX_TTL_MS / SANDBOX_MAX_BYTES /
  SANDBOX_MAX_IMAGE_BYTES / SANDBOX_MAX_TOTAL_BYTES wired through the
  environment service + validation + .env.example.
- SandboxModule (@Global) provides the shared store to the controller,
  McpService and AiChatToolsService (same instance for put and get).

Tests: SandboxStore (round-trip, sha256, TTL lazy + sweep, caps, eviction),
SandboxController (200+ETag+CT+CL, 404 missing/expired/non-UUID, 304), and a
mock-HTTP stashPage test (mirror+rewrite internal, keep external, dedup, failed
image counted, returns only a link). Interoperates with the vvzvlad/habr-mcp
consumer's anonymous-GET + sha256-ETag + resource_link contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-28 18:03:01 +03:00
Security:
- stash_page: reject path-traversal / percent-encoded srcs before the authed
  loopback fetch (resolveInternalFilePath), closing an SSRF/exfiltration hole
  where a crafted node.attrs.src could read an arbitrary internal GET endpoint
  into the anonymous sandbox.

Stability:
- stash_page: revert + recount mirrors FIFO-evicted by a later put in the same
  stash (no dangling sandbox refs, honest images.mirrored/failed); free image
  blobs if the final document put throws.
- Reject/clamp non-positive SANDBOX_TTL_MS to the 1h default (warn once).
- Log mirror failures unconditionally (console.warn, no blob bodies).

Cleanup / architecture:
- Remove dead expiresAt from SandboxPutResult.
- Centralize the /api/sb route in SANDBOX_ROUTE_SEGMENT/SANDBOX_API_PATH and
  move URL composition into SandboxStore.putAndLink; drop the duplicated sink
  closures and the now-unused EnvironmentService injection from McpService and
  AiChatToolsService.
- Un-export isInternalFileUrl; document the process-local (instance-bound)
  sandbox limitation in the tool description and .env.example.

Docs/tests:
- README/README.ru: 38 -> 39 tools + stash_page entry.
- Add traversal/normalize/recursion unit tests, stash self-eviction +
  doc-put-throw + empty/octet-stream mock tests, controller If-None-Match
  (wildcard/weak/list) + Cache-Control tests, and SANDBOX_TTL_MS validation
  tests. Regenerate packages/mcp/build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner

Правки по ревью — готово (commit 6eb335d5)

Все замечания закрыты в feat/243-blob-sandbox. Сборка packages/mcp/build/ пересобрана tsc и сверена с src. Тесты зелёные: MCP 344 unit + 34 mock, сервер 34 (sandbox + environment).

🔴 Must fix

  • [security/critical] Traversal/SSRF в fetchInternalFile — добавлена чистая resolveInternalFilePath(src) (packages/mcp/src/lib/internal-file-urls.ts): отвергает %2e/%2f, канонизирует путь через new URL(src, "http://internal.invalid").pathname и требует startsWith("/api/files/").. схлопывается и выход из /api/files/ отклоняется до client.get. fetchInternalFile теперь фетчит только возвращённый относительный путь; вводящий в заблуждение комментарий переписан. Отклонённый src идёт в failed, документ не рушится.
  • [documentation] README 38 → 39 — обновлены счётчики и добавлена запись про stash_page в README.md и README.ru.md.
  • [simplification] Мёртвое expiresAt — убрано из SandboxPutResult и из return в put(); SandboxEntry.expiresAt (нужно контроллеру/get/sweep) не тронут.

⚠️ Стабильность / suggestions

  • Самовытеснение картинокstashPage фиксирует per-node оригинальный src, и если блоб вытеснен FIFO более поздним put в этой же стэш-операции, откатывает src узлов к оригиналу и пересчитывает mirrored/failed (через новые пробы has/evict у sink). Битых ссылок в документе и вранья в счётчике больше нет.
  • Освобождение блобов при throw на put документа — финальный put обёрнут в try/catch: при исключении блобы картинок этой операции вытесняются перед re-throw.
  • SANDBOX_TTL_MS ≤ 0 / нечисловойgetSandboxTtlMs отвергает невалидное значение, откатывается к дефолту 3600000 мс и предупреждает один раз (без спама на каждый put).
  • Логирование ошибок зеркалированияconsole.warn без гейта на DEBUG (без тел блобов); под него же попадают и отклонённые traversal-срцы.
  • isInternalFileUrl — снят export (используется только внутри файла).

🏛️ Архитектура (обе опции реализованы)

  • Дедуп композиции URLSandboxStore.putAndLink(buf, mime) владеет формой URL; маршрут вынесен в SANDBOX_ROUTE_SEGMENT/SANDBOX_API_PATH (sandbox.constants.ts) и используется контроллером (@Controller), main.ts (excludedPaths) и putAndLink. Дублирующие замыкания и ставшая ненужной инъекция EnvironmentService убраны из McpService и AiChatToolsService.
  • Process-local ограничение — задокументировано в описании stash_page (tool-specs.ts) и в секции SANDBOX_PUBLIC_URL в .env.example: блобы привязаны к инстансу, при multi-replica без sticky-сессий консьюмер может попасть на другой инстанс и получить 404.

🧪 Тесты

  • Новый packages/mcp/test/unit/internal-file-urls.test.mjs: приём нормального src и отклонение traversal/%-вариантов; ветка /files/ → /api/files/; рекурсия collectInternalFileNodes во вложенный content.
  • stash-page.test.mjs: self-eviction-revert, throw на put документа освобождает блобы, пустой ответ → failed, отсутствие Content-Typeapplication/octet-stream.
  • sandbox.controller.spec.ts: If-None-Match wildcard * / weak W/ / список через запятую; ассерт Cache-Control и max-age/ttlSeconds.
  • environment.service.spec.ts: валидация SANDBOX_TTL_MS (0/-5/abc → дефолт, валидное положительное → проходит).
## Правки по ревью — готово ✅ (commit `6eb335d5`) Все замечания закрыты в `feat/243-blob-sandbox`. Сборка `packages/mcp/build/` пересобрана `tsc` и сверена с `src`. Тесты зелёные: MCP 344 unit + 34 mock, сервер 34 (sandbox + environment). ### 🔴 Must fix - **[security/critical] Traversal/SSRF в `fetchInternalFile`** — добавлена чистая `resolveInternalFilePath(src)` (`packages/mcp/src/lib/internal-file-urls.ts`): отвергает `%2e`/`%2f`, канонизирует путь через `new URL(src, "http://internal.invalid").pathname` и требует `startsWith("/api/files/")` — `..` схлопывается и выход из `/api/files/` отклоняется **до** `client.get`. `fetchInternalFile` теперь фетчит только возвращённый относительный путь; вводящий в заблуждение комментарий переписан. Отклонённый src идёт в `failed`, документ не рушится. - **[documentation] README 38 → 39** — обновлены счётчики и добавлена запись про `stash_page` в `README.md` и `README.ru.md`. - **[simplification] Мёртвое `expiresAt`** — убрано из `SandboxPutResult` и из `return` в `put()`; `SandboxEntry.expiresAt` (нужно контроллеру/`get`/`sweep`) не тронут. ### ⚠️ Стабильность / suggestions - **Самовытеснение картинок** — `stashPage` фиксирует per-node оригинальный `src`, и если блоб вытеснен FIFO более поздним put в этой же стэш-операции, откатывает `src` узлов к оригиналу и пересчитывает `mirrored/failed` (через новые пробы `has`/`evict` у sink). Битых ссылок в документе и вранья в счётчике больше нет. - **Освобождение блобов при throw на put документа** — финальный put обёрнут в try/catch: при исключении блобы картинок этой операции вытесняются перед re-throw. - **`SANDBOX_TTL_MS ≤ 0` / нечисловой** — `getSandboxTtlMs` отвергает невалидное значение, откатывается к дефолту 3600000 мс и предупреждает один раз (без спама на каждый put). - **Логирование ошибок зеркалирования** — `console.warn` без гейта на `DEBUG` (без тел блобов); под него же попадают и отклонённые traversal-срцы. - **`isInternalFileUrl`** — снят `export` (используется только внутри файла). ### 🏛️ Архитектура (обе опции реализованы) - **Дедуп композиции URL** — `SandboxStore.putAndLink(buf, mime)` владеет формой URL; маршрут вынесен в `SANDBOX_ROUTE_SEGMENT`/`SANDBOX_API_PATH` (`sandbox.constants.ts`) и используется контроллером (`@Controller`), `main.ts` (`excludedPaths`) и `putAndLink`. Дублирующие замыкания и ставшая ненужной инъекция `EnvironmentService` убраны из `McpService` и `AiChatToolsService`. - **Process-local ограничение** — задокументировано в описании `stash_page` (`tool-specs.ts`) и в секции `SANDBOX_PUBLIC_URL` в `.env.example`: блобы привязаны к инстансу, при multi-replica без sticky-сессий консьюмер может попасть на другой инстанс и получить 404. ### 🧪 Тесты - Новый `packages/mcp/test/unit/internal-file-urls.test.mjs`: приём нормального src и отклонение traversal/`%`-вариантов; ветка `/files/ → /api/files/`; рекурсия `collectInternalFileNodes` во вложенный `content`. - `stash-page.test.mjs`: self-eviction-revert, throw на put документа освобождает блобы, пустой ответ → `failed`, отсутствие `Content-Type` → `application/octet-stream`. - `sandbox.controller.spec.ts`: `If-None-Match` wildcard `*` / weak `W/` / список через запятую; ассерт `Cache-Control` и `max-age`/`ttlSeconds`. - `environment.service.spec.ts`: валидация `SANDBOX_TTL_MS` (`0`/`-5`/`abc` → дефолт, валидное положительное → проходит).
vvzvlad added 1 commit 2026-06-28 19:19:53 +03:00
Security (must-fix):
- sandbox.controller: the anonymous GET /api/sb/:id response now sets
  X-Content-Type-Options: nosniff, a restrictive CSP, and Content-Disposition=
  attachment for any mime outside a raster-image allowlist (png/jpeg/gif/webp/
  avif). entry.mime is attacker-controlled, so an evil.svg/evil.html could
  otherwise execute script inline on the Docmost origin (stored XSS). Mirrors
  the public attachment route's hardening.

Stability:
- client.stashPage: reconcile mirrors AFTER the final document put, not only
  before it. The doc blob is the newest entry and FIFO eviction drops the
  oldest = this stash's own images, so the stored doc could reference an
  evicted blob (consumer 404) and over-report images.mirrored. A bounded loop
  now reverts doc-put-evicted mirrors, drops the stale doc blob, and re-puts
  until stable. Regenerated packages/mcp/build/.
- sandbox.controller: emit Cache-Control on the 304 branch too (ttlSeconds is
  computed before the conditional check).

Docs:
- Bump the MCP tool count 39 -> 40 across all READMEs and AGENTS.md (the
  registry now exposes exactly 40 tools).

Refactor:
- SandboxStore.asSink() centralizes the {put,has,evict} sink + uri<->id
  mapping; the embedded-MCP and in-app agent-tools wiring sites share it.

Tests:
- security headers (inline vs attachment, nosniff, CSP), 304 Cache-Control,
  putAndLink URL form, has()/remove(), asSink() round-trip, getSandboxPublicUrl
  (trailing-slash trim + APP_URL fallback), and a stash test where the doc put
  itself evicts a mirrored image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner

Code review — in-RAM blob sandbox + stash_page

Вердикт: Request changes — изменение по сути отличного качества (security, stability, регрессии и покрытие тестами — чисто), но есть один обязательный к правке пункт перед merge: внесённый этим PR «мёртвый» импорт + устаревший комментарий в mcp.module.ts. Это change-introduced debt с малым рантайм-радиусом, но по правилам — must-fix, а не отложенный «nice-to-have». Поправить — и можно мёржить.

Отревьюено: git diff --merge-base develop feat/243-blob-sandbox (33 файла, +2222/−19). Сгенерированные packages/mcp/build/*.js проверены только на синхронность с src/*.ts — синхронны. Прогон вёлся 8 специализированными ревьюерами (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture).

Must fix before merge

  • [conventions/documentation] Убрать устаревший импорт EnvironmentModule и комментарий в McpModuleapps/server/src/integrations/mcp/mcp.module.ts:5-15
    Этот PR удалил EnvironmentService из mcp.service.ts (импорт + параметр конструктора, заменены на SandboxStore). После этого ни один провайдер McpModule (McpService, McpController, mcp-auth.helpers) больше не потребляет EnvironmentService — проверено на head ветки. Импорт EnvironmentModule стал мёртвым (формально безвреден, т.к. модуль @Global), а комментарий по-прежнему перечисляет его как поставщика EnvironmentService для этого модуля — обоснование импорта теперь ложное.
    Fix: удалить EnvironmentModule из imports (он @Global, прочие зависимости резолвятся и так) и убрать соответствующую фразу из комментария; либо, если импорт оставляют для наглядности, переписать комментарий, чтобы он не приписывал импорт несуществующей зависимости McpService.

Non-blocking findings

  • [suggestion][stability] Валидировать неположительные SANDBOX_MAX_BYTES / MAX_IMAGE_BYTES / MAX_TOTAL_BYTESapps/server/src/integrations/environment/environment.service.ts:384-407
    getSandboxTtlMs() отбрасывает <= 0/нецелые и откатывается к дефолту, а три новых байтовых геттера делают голый parseInt без пост-валидации; @IsNumberString пропускает "0", "-5", "1.5". При SANDBOX_MAX_TOTAL_BYTES=0/отриц. каждый stash_page падает с невнятной ошибкой «exceeds the total store cap of 0 bytes», "...5" молча округляется. Бесконечного цикла эвикции нет (проверка buf.length > maxTotal срабатывает до while). Fix: по аналогии с getSandboxTtlMs() — при !Number.isInteger(parsed) || parsed <= 0 варнинг + дефолт.

  • [suggestion][simplification] Заменить самодельный UUID_RE на существующий валидатор uuidapps/server/src/integrations/sandbox/sandbox.controller.ts:6-7
    В проекте уже есть зависимость uuid и устоявшийся паттерн import { validate as isValidUUID } from 'uuid' (attachment.controller.ts — 4 использования), на который ссылается и сам докстринг контроллера. Отдаются randomUUID() (v4), которые uuid.validate() принимает. Заодно убираются две копии regex в тестах.

  • [suggestion][simplification] Заинлайнить однострочную обёртку buildSandboxConfig()apps/server/src/integrations/mcp/mcp.service.ts:119-125
    Тело — return this.sandboxStore.asSink();, единственный вызов. Лишний слой косвенности без собственной логики; пояснительный комментарий уже продублирован в месте wiring в ai-chat-tools.service.ts.

  • [suggestion][simplification] Сделать has() / remove() приватнымиapps/server/src/integrations/sandbox/sandbox.store.ts
    Их единственный не-тестовый вызыватель — asSink() того же класса; remove() — однострочная обёртка над приватным evict(). Опционально (покрыты юнит-тестами напрямую).

  • [suggestion][documentation] Добавить запись в [Unreleased] > Added CHANGELOGCHANGELOG.md:11-13
    Раздел [Unreleased] > Added фиксирует свежие пользовательские фичи по номеру issue (#198, #222, #228), а #243 (новый MCP-инструмент stash_page, анонимный GET /api/sb/:id, пять SANDBOX_* env) диффом не затронут. Non-blocking: AGENTS.md формально описывает датированный блок как сборку на релизе, но де-факто Unreleased-записи в фиче-PR добавляют.

  • [suggestion][documentation] Упомянуть анонимный маршрут /api/sb там, где AGENTS.md описывает auth-модельAGENTS.md:244
    Появился второй анонимный, освобождённый от workspace-gate маршрут (рядом с /api/files/public) — новое исключение к «most /api routes». Прямого противоречия нет (раздел обзорный), confidence низкий — на усмотрение автора.

Test coverage

Покрытие необычно полное — LGTM. Осмысленно заасерчены: sandbox.store (TTL/lazy-expiry, per-blob mime-cap, total-RAM guard + FIFO-эвикция, 60s sweep, onModuleDestroy, sha256/ETag), sandbox.controller (invalid-UUID/missing/expired→404, happy-path с заголовками, 304 на все варианты If-None-Match: quoted/bare/*/W//comma-list), stash_page (mirror+rewrite, внешний CDN не трогается, dedup, упавшая картинка не прерывает операцию, not-configured guard), env-геттеры с реальной логикой (success + invalid). Непокрыто только тривиальное: три cap-геттера = голый parseInt pass-through и декларативные валидаторы (как и весь существующий аналогичный код проекта) — не пробел.

Architecture & design (forward-looking, non-blocking)

Обе развилки — осознанные, текущая реализация когерентна и мёржу не мешает; решает автор.

1. In-RAM-only хранилище vs существующая абстракция StorageDriver (integrations/sandbox/sandbox.store.ts vs integrations/storage/)
Введён второй, процесс-локальный RAM-only механизм хранения блобов, отдельный от durable-абстракции (local/S3/Azure). Следствие честно задокументировано (.env.example, описание tool-spec): за балансировщиком на несколько реплик без sticky-сессий put на ноде A и GET на ноде B вернёт 404, неотличимый от истечения TTL. Публичный контракт (GET /api/sb/:id) — трудноменяемая часть; бэкенд за ним менять легко, отсрочка дёшева.

  • A — оставить RAM-only (как есть) (effort: s). + просто, без инфраструктуры, гарантированная эфемерность (рестарт стирает capability-URL — плюс для анонимного канала), полностью покрыто тестами. − молча неверно при multi-replica без sticky; режим отказа не наблюдаем потребителем.
  • B — переключить на StorageDriver (ephemeral-префикс + TTL-cleanup) (effort: m). + multi-replica-safe для S3/Azure из коробки, переиспользует единственный storage-seam, переживает рестарт в пределах TTL. − блобы на диск/в object-store (нужен надёжный TTL-delete, риск orphan'ов), теряется «стирание на рестарте», local-драйвер всё равно не шарится.
  • C — RAM по умолчанию, но за маленьким internal-интерфейсом (effort: s). + сохраняет простоту, локализует будущую замену. − спекулятивно до второго бэкенда (абстракция с одной реализацией).
  • Рекомендация: A для этого PR — дизайн согласован, ограничение задокументировано, эфемерность — плюс к безопасности. Если multi-replica в близких планах — B; иначе абстракцию из C заранее не строить.

2. Дублирование internal-file-urls.ts с хелперами editor-ext (packages/mcp/src/lib/internal-file-urls.ts)
Заново реализованы isInternalFileUrl / normalizeFileUrl, уже существующие в packages/editor-ext (сам заголовочный комментарий это признаёт — дубль, чтобы ESM-пакет mcp не зависел от сборки editor-ext). Действительно новая логика (resolveInternalFilePath — SSRF/traversal-guard, и collectInternalFileNodes) — легитимно пакето-специфична. Риск расхождения низкий (два тривиальных предиката, стабильные константы), но это введённый дубль.

  • A — оставить локальный дубль (как есть) с комментарием (effort: s). + ESM-пакет mcp собирается изолированно, без браузерных зависимостей Tiptap. − два источника истины.
  • B — вынести два предиката в крошечный модуль без зависимостей (effort: m). + единое определение в монорепо. − нужен новый leaf-пакет/аккуратный импорт, чтобы не затащить браузерные зависимости editor-ext в ESM-сборку.
  • Рекомендация: без сильного предпочтения; A защитим (реальная стоимость build-границы + тривиальность предикатов), к мёржу годен.

Что специально проверено и признано корректным

  • Security (LGTM): строгий UUID-regex до обращения к store; 122-битный случайный UUID (нет IDOR); nosniff + ограничительный CSP + Content-Disposition: attachment для не-растровых mime (svg/html/json), inline только для allowlist растров → нет stored-XSS даже при влиянии на mime; missing и expired оба → 404 (нет утечки); SSRF исключёнattrs.src собирается только при префиксе /api/files/ или /files/, resolveInternalFilePath блокирует %2e/%2f/..///host и удерживает путь под /api/files/; секретов в диффе нет; RAM ограничен (caps до вставки + FIFO + sweep).
  • Stability (LGTM, кроме пункта выше): put/get/sweep/evict синхронны (нет await между read и mutate) → нет внутри-store-гонок; байтовый учёт консистентен; блоб не вытесняет сам себя; sweep-таймер unref'нут и очищается в onModuleDestroy; одна упавшая картинка не прерывает операцию (конкурентность ≤5, таймаут 30с, потолок 64 MiB на fetch); for(;;)-реконсиляция FIFO завершается; при ошибке put блобы операции откатываются.
  • Regressions (LGTM): префикс /api/sb в excludedPaths не коллизирует с существующими маршрутами; @Global SandboxModule — порядок импорта неважен, дублей APP_GUARD нет; изменения shared MCP-файлов аддитивны; build-артефакты пересобраны и побайтово совпадают.

Bugs: critical/warning-дефектов корректности или безопасности — ноль. Единственный must-fix — косметический change-introduced debt (мёртвый импорт + устаревший комментарий) в mcp.module.ts. Остальное — необязательные улучшения и две архитектурные развилки на усмотрение автора.


🤖 Сгенерировано code-review-orchestrator (8 параллельных ревьюеров + judge-pass).

## Code review — in-RAM blob sandbox + `stash_page` **Вердикт: Request changes** — изменение по сути отличного качества (security, stability, регрессии и покрытие тестами — чисто), но есть один обязательный к правке пункт перед merge: внесённый этим PR «мёртвый» импорт + устаревший комментарий в `mcp.module.ts`. Это change-introduced debt с малым рантайм-радиусом, но по правилам — must-fix, а не отложенный «nice-to-have». Поправить — и можно мёржить. Отревьюено: `git diff --merge-base develop feat/243-blob-sandbox` (33 файла, +2222/−19). Сгенерированные `packages/mcp/build/*.js` проверены только на синхронность с `src/*.ts` — синхронны. Прогон вёлся 8 специализированными ревьюерами (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture). ### Must fix before merge - **[conventions/documentation] Убрать устаревший импорт `EnvironmentModule` и комментарий в `McpModule`** — `apps/server/src/integrations/mcp/mcp.module.ts:5-15` Этот PR удалил `EnvironmentService` из `mcp.service.ts` (импорт + параметр конструктора, заменены на `SandboxStore`). После этого **ни один** провайдер `McpModule` (`McpService`, `McpController`, `mcp-auth.helpers`) больше не потребляет `EnvironmentService` — проверено на head ветки. Импорт `EnvironmentModule` стал мёртвым (формально безвреден, т.к. модуль `@Global`), а комментарий по-прежнему перечисляет его как поставщика `EnvironmentService` для этого модуля — обоснование импорта теперь ложное. **Fix:** удалить `EnvironmentModule` из `imports` (он `@Global`, прочие зависимости резолвятся и так) и убрать соответствующую фразу из комментария; либо, если импорт оставляют для наглядности, переписать комментарий, чтобы он не приписывал импорт несуществующей зависимости `McpService`. ### Non-blocking findings - **[suggestion][stability] Валидировать неположительные `SANDBOX_MAX_BYTES` / `MAX_IMAGE_BYTES` / `MAX_TOTAL_BYTES`** — `apps/server/src/integrations/environment/environment.service.ts:384-407` `getSandboxTtlMs()` отбрасывает `<= 0`/нецелые и откатывается к дефолту, а три новых байтовых геттера делают голый `parseInt` без пост-валидации; `@IsNumberString` пропускает `"0"`, `"-5"`, `"1.5"`. При `SANDBOX_MAX_TOTAL_BYTES=0`/отриц. каждый `stash_page` падает с невнятной ошибкой «exceeds the total store cap of 0 bytes», `"...5"` молча округляется. Бесконечного цикла эвикции нет (проверка `buf.length > maxTotal` срабатывает до `while`). **Fix:** по аналогии с `getSandboxTtlMs()` — при `!Number.isInteger(parsed) || parsed <= 0` варнинг + дефолт. - **[suggestion][simplification] Заменить самодельный `UUID_RE` на существующий валидатор `uuid`** — `apps/server/src/integrations/sandbox/sandbox.controller.ts:6-7` В проекте уже есть зависимость `uuid` и устоявшийся паттерн `import { validate as isValidUUID } from 'uuid'` (`attachment.controller.ts` — 4 использования), на который ссылается и сам докстринг контроллера. Отдаются `randomUUID()` (v4), которые `uuid.validate()` принимает. Заодно убираются две копии regex в тестах. - **[suggestion][simplification] Заинлайнить однострочную обёртку `buildSandboxConfig()`** — `apps/server/src/integrations/mcp/mcp.service.ts:119-125` Тело — `return this.sandboxStore.asSink();`, единственный вызов. Лишний слой косвенности без собственной логики; пояснительный комментарий уже продублирован в месте wiring в `ai-chat-tools.service.ts`. - **[suggestion][simplification] Сделать `has()` / `remove()` приватными** — `apps/server/src/integrations/sandbox/sandbox.store.ts` Их единственный не-тестовый вызыватель — `asSink()` того же класса; `remove()` — однострочная обёртка над приватным `evict()`. Опционально (покрыты юнит-тестами напрямую). - **[suggestion][documentation] Добавить запись в `[Unreleased] > Added` CHANGELOG** — `CHANGELOG.md:11-13` Раздел `[Unreleased] > Added` фиксирует свежие пользовательские фичи по номеру issue (#198, #222, #228), а #243 (новый MCP-инструмент `stash_page`, анонимный `GET /api/sb/:id`, пять `SANDBOX_*` env) диффом не затронут. Non-blocking: AGENTS.md формально описывает датированный блок как сборку на релизе, но де-факто Unreleased-записи в фиче-PR добавляют. - **[suggestion][documentation] Упомянуть анонимный маршрут `/api/sb` там, где AGENTS.md описывает auth-модель** — `AGENTS.md:244` Появился второй анонимный, освобождённый от workspace-gate маршрут (рядом с `/api/files/public`) — новое исключение к «most /api routes». Прямого противоречия нет (раздел обзорный), confidence низкий — на усмотрение автора. ### Test coverage Покрытие необычно полное — **LGTM**. Осмысленно заасерчены: `sandbox.store` (TTL/lazy-expiry, per-blob mime-cap, total-RAM guard + FIFO-эвикция, 60s sweep, `onModuleDestroy`, sha256/ETag), `sandbox.controller` (invalid-UUID/missing/expired→404, happy-path с заголовками, 304 на все варианты `If-None-Match`: quoted/bare/`*`/`W/`/comma-list), `stash_page` (mirror+rewrite, внешний CDN не трогается, dedup, упавшая картинка не прерывает операцию, not-configured guard), env-геттеры с реальной логикой (success + invalid). Непокрыто только тривиальное: три cap-геттера = голый `parseInt` pass-through и декларативные валидаторы (как и весь существующий аналогичный код проекта) — не пробел. ### Architecture & design (forward-looking, non-blocking) Обе развилки — осознанные, текущая реализация когерентна и мёржу не мешает; решает автор. **1. In-RAM-only хранилище vs существующая абстракция `StorageDriver`** (`integrations/sandbox/sandbox.store.ts` vs `integrations/storage/`) Введён второй, процесс-локальный RAM-only механизм хранения блобов, отдельный от durable-абстракции (local/S3/Azure). Следствие честно задокументировано (`.env.example`, описание tool-spec): за балансировщиком на несколько реплик без sticky-сессий `put` на ноде A и `GET` на ноде B вернёт 404, неотличимый от истечения TTL. Публичный контракт (`GET /api/sb/:id`) — трудноменяемая часть; бэкенд за ним менять легко, отсрочка дёшева. - *A — оставить RAM-only (как есть)* (effort: s). + просто, без инфраструктуры, гарантированная эфемерность (рестарт стирает capability-URL — плюс для анонимного канала), полностью покрыто тестами. − молча неверно при multi-replica без sticky; режим отказа не наблюдаем потребителем. - *B — переключить на `StorageDriver` (ephemeral-префикс + TTL-cleanup)* (effort: m). + multi-replica-safe для S3/Azure из коробки, переиспользует единственный storage-seam, переживает рестарт в пределах TTL. − блобы на диск/в object-store (нужен надёжный TTL-delete, риск orphan'ов), теряется «стирание на рестарте», local-драйвер всё равно не шарится. - *C — RAM по умолчанию, но за маленьким internal-интерфейсом* (effort: s). + сохраняет простоту, локализует будущую замену. − спекулятивно до второго бэкенда (абстракция с одной реализацией). - **Рекомендация:** A для этого PR — дизайн согласован, ограничение задокументировано, эфемерность — плюс к безопасности. Если multi-replica в близких планах — B; иначе абстракцию из C заранее не строить. **2. Дублирование `internal-file-urls.ts` с хелперами `editor-ext`** (`packages/mcp/src/lib/internal-file-urls.ts`) Заново реализованы `isInternalFileUrl` / `normalizeFileUrl`, уже существующие в `packages/editor-ext` (сам заголовочный комментарий это признаёт — дубль, чтобы ESM-пакет mcp не зависел от сборки editor-ext). Действительно новая логика (`resolveInternalFilePath` — SSRF/traversal-guard, и `collectInternalFileNodes`) — легитимно пакето-специфична. Риск расхождения низкий (два тривиальных предиката, стабильные константы), но это введённый дубль. - *A — оставить локальный дубль (как есть) с комментарием* (effort: s). + ESM-пакет mcp собирается изолированно, без браузерных зависимостей Tiptap. − два источника истины. - *B — вынести два предиката в крошечный модуль без зависимостей* (effort: m). + единое определение в монорепо. − нужен новый leaf-пакет/аккуратный импорт, чтобы не затащить браузерные зависимости editor-ext в ESM-сборку. - **Рекомендация:** без сильного предпочтения; A защитим (реальная стоимость build-границы + тривиальность предикатов), к мёржу годен. ### Что специально проверено и признано корректным - **Security (LGTM):** строгий UUID-regex до обращения к store; 122-битный случайный UUID (нет IDOR); `nosniff` + ограничительный CSP + `Content-Disposition: attachment` для не-растровых mime (svg/html/json), inline только для allowlist растров → нет stored-XSS даже при влиянии на mime; missing и expired оба → 404 (нет утечки); **SSRF исключён** — `attrs.src` собирается только при префиксе `/api/files/` или `/files/`, `resolveInternalFilePath` блокирует `%2e`/`%2f`/`..`/`//host` и удерживает путь под `/api/files/`; секретов в диффе нет; RAM ограничен (caps до вставки + FIFO + sweep). - **Stability (LGTM, кроме пункта выше):** `put`/`get`/`sweep`/`evict` синхронны (нет await между read и mutate) → нет внутри-store-гонок; байтовый учёт консистентен; блоб не вытесняет сам себя; sweep-таймер `unref`'нут и очищается в `onModuleDestroy`; одна упавшая картинка не прерывает операцию (конкурентность ≤5, таймаут 30с, потолок 64 MiB на fetch); `for(;;)`-реконсиляция FIFO завершается; при ошибке put блобы операции откатываются. - **Regressions (LGTM):** префикс `/api/sb` в `excludedPaths` не коллизирует с существующими маршрутами; `@Global SandboxModule` — порядок импорта неважен, дублей `APP_GUARD` нет; изменения shared MCP-файлов аддитивны; build-артефакты пересобраны и побайтово совпадают. **Bugs:** critical/warning-дефектов корректности или безопасности — ноль. Единственный must-fix — косметический change-introduced debt (мёртвый импорт + устаревший комментарий) в `mcp.module.ts`. Остальное — необязательные улучшения и две архитектурные развилки на усмотрение автора. --- 🤖 Сгенерировано code-review-orchestrator (8 параллельных ревьюеров + judge-pass).
vvzvlad added 1 commit 2026-06-28 20:21:47 +03:00
Must-fix:
- mcp.module: drop the now-dead EnvironmentModule import (and its stale
  comment). McpService no longer injects EnvironmentService; EnvironmentModule
  is @Global and imported at the app root, so DI still resolves.

Stability:
- environment.service: route getSandboxTtlMs + the three SANDBOX_MAX_*_BYTES
  caps through a shared getPositiveIntEnv() helper that warns once per key and
  falls back to the default on a non-integer or <= 0 value (previously the byte
  caps did a bare parseInt, so SANDBOX_MAX_TOTAL_BYTES=0 made every stash_page
  fail against a 0-byte cap). TTL behavior is unchanged.

Simplification:
- sandbox.controller: replace the homemade UUID_RE with the project's shared
  `uuid` validator (import { validate as isValidUUID } from 'uuid'), matching
  the attachment routes; update the spec fixtures to valid v4 UUIDs.
- mcp.service: inline the single-caller one-liner buildSandboxConfig() to
  this.sandboxStore.asSink() at the wiring site.

Docs:
- CHANGELOG: add an [Unreleased] > Added entry for #243 (stash_page tool,
  anonymous GET /api/sb/:id, five SANDBOX_* env vars).
- AGENTS.md: note that GET /api/sb/:id is in the workspace-gate preHandler's
  excludedPaths and is fully tokenless, unlike /api/files/public/... which
  still resolves a workspace and needs an attachment JWT.

Tests: cap-getter validation (0/-5/abc -> default, valid -> parsed), updated
UUID fixtures. apps/server jest sandbox/environment/mcp: 233 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner

Код-ревью — in-RAM blob sandbox (анонимный GET по UUID, TTL, ETag) + stash_page с зеркалированием картинок

Вердикт: Approve with comments (одобрить с замечаниями).

Изменение спроектировано аккуратно и хорошо покрыто тестами. Многоаспектное ревью (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture); 4 аспекта — чистый LGTM (security, stability, regressions, conventions). Блокирующих замечаний нет — ни одно из найденных не меняет вердикт мерджа. Ревью охватывало diff относительно merge-base с develop (35 файлов, +2280/−26); сгенерированные packages/mcp/build/*.js исключены (правятся из src/).

Проверено пристально и оказалось корректным:

  • Анонимный маршрут GET /api/sb/:id: глобального APP_GUARD в приложении нет, поэтому отсутствие JwtAuthGuard действительно делает роут анонимным — ровно как GET /api/files/public/.... Отдаются только блобы из Map по валидированному UUID, traversal-поверхности нет, токены не утекают, угла для enumeration нет.
  • SSRF / обход пути в resolveInternalFilePath: защита держит литеральный .., %2e/%2f, backslash, таб/перевод строки, null-байт, protocol-relative и absolute-external (хост отбрасывается, проходят только pathname с префиксом /api/files/). Запрос остаётся на loopback, CASL работает за счёт пользовательского JWT.
  • XSS / Content-Type: контроллер строже precedent'а — CSP + X-Content-Type-Options: nosniff + allowlist inline-mime, всё не из списка (svg/html/json) форсится в attachment; mime санируется.
  • RAM/DoS и FIFO-реконсиляция в stashPage: totalBytes не дрейфует, циклы эвикции терминируются, счётчики mirrored/failed не уходят в минус, блобы освобождаются при ошибке put дока. DI после удаления EnvironmentModule из mcp.module.ts корректен (EnvironmentService там больше не используется).

Обязательно до мерджа

  • [warning][test-coverage] Добавить тесты resolveInternalFilePath на accept-путь absolute-URL/scheme и backslashpackages/mcp/src/lib/internal-file-urls.ts:61-87. Тесты покрывают только .. и %2e/%2f-reject. Не зафиксирован неочевид��ый accept-путь: http://evil.com/api/files/x/y.png отбрасывает хост и возвращает /files/x/y.png (это безопасно — baseURL axios на loopback, хост игнорируется, реального SSRF нет), но именно отбрасывание хоста ничем не закреплено. Так как это единственная защита от SSRF/traversal для content-controlled src, будущий рефакторинг (например, переход на prefix-only проверку) может молча открыть обход без падающего теста. Fix: добавить кейсы — absolute-URL с чужим хостом резолвится в /files/x/y.png; /api/files\..\auth\whoami бросает; https://evil.com/api/auth/whoami бросает.

  • [suggestion][documentation] Согласовать MCP-результат stash_page (только resource_link) с задокументированной формой { uri, size, sha256, images }packages/mcp/src/index.ts:414-433. Описание в tool-specs.ts и оба README обещают Returns { uri, size, sha256, images:{mirrored, failed} }. Это верно для in-app пути (ai-chat-tools.service.ts отдаёт полный объект), но MCP-транспорт возвращает только resource_link с uri/name/mimeType/size — без sha256 и images. Радиус мал (sha256 доступен как ETag блоба, ссылка — основной payload), поэтому non-blocking. Fix: либо добавить structuredContent: { uri, sha256, size, images } рядом с resource_link в index.ts, либо смягчить общее описание (по MCP возвращается resource_link, а sha256 — это ETag блоба).

  • [suggestion][test-coverage] Покрыть catch-ветку new URLpackages/mcp/src/lib/internal-file-urls.ts:72-78. Ветка «Invalid internal file src» достижима (например, http://[), но не тестируется. Fix: добавить assert.throws на malformed-src.

  • [suggestion][test-coverage] Проверить one-shot-warn дедуп в getPositiveIntEnvenvironment.service.ts:360-375. Тесты проверяют fallback-значение, но не то, что logger.warn срабатывает один раз на ключ (ради чего и заведён invalidPositiveIntWarned). Fix: кейс с invalid env, спай на logger.warn, два вызова геттера, ожидание ровно одного warn.

Покрытие тестами

Покрытие сильное. Полноценно протестированы: SandboxStore (per-blob/total caps, FIFO-эвикция, lazy expiry, sweep, has/remove, putAndLink/asSink round-trip, учёт байт), SandboxController (200 с ETag/Cache-Control/nosniff/CSP, inline-vs-attachment по mime, 404 на non-UUID/missing, 304 для quoted/bare/W//списка/*), stashPage (зеркалирование+rewrite, dedup, внешний CDN не трогается, failed-image без аборта, not-configured guard, и все три ветки FIFO-реконсиляции, включая doc-put-throws), геттеры env (fallback). Незакрытые места — только три перечисленных выше; главное из них — security-critical accept-путь resolveInternalFilePath.

## Код-ревью — in-RAM blob sandbox (анонимный GET по UUID, TTL, ETag) + `stash_page` с зеркалированием картинок **Вердикт: Approve with comments (одобрить с замечаниями).** Изменение спроектировано аккуратно и хорошо покрыто тестами. Многоаспектное ревью (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture); 4 аспекта — чистый LGTM (security, stability, regressions, conventions). Блокирующих замечаний нет — ни одно из найденных не меняет вердикт мерджа. Ревью охватывало diff относительно merge-base с `develop` (35 файлов, +2280/−26); сгенерированные `packages/mcp/build/*.js` исключены (правятся из `src/`). Проверено пристально и оказалось корректным: - **Анонимный маршрут** [`GET /api/sb/:id`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/apps/server/src/integrations/sandbox/sandbox.controller.ts): глобального `APP_GUARD` в приложении нет, поэтому отсутствие `JwtAuthGuard` действительно делает роут анонимным — ровно как `GET /api/files/public/...`. Отдаются только блобы из `Map` по валидированному UUID, traversal-поверхности нет, токены не утекают, угла для enumeration нет. - **SSRF / обход пути** в [`resolveInternalFilePath`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/packages/mcp/src/lib/internal-file-urls.ts#L61-L87): защита держит литеральный `..`, `%2e`/`%2f`, backslash, таб/перевод строки, null-байт, protocol-relative и absolute-external (хост отбрасывается, проходят только pathname с префиксом `/api/files/`). Запрос остаётся на loopback, CASL работает за счёт пользовательского JWT. - **XSS / Content-Type**: контроллер строже precedent'а — CSP + `X-Content-Type-Options: nosniff` + allowlist inline-mime, всё не из списка (svg/html/json) форсится в `attachment`; mime санируется. - **RAM/DoS и FIFO-реконсиляция** в [`stashPage`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/packages/mcp/src/client.ts#L858-L999): `totalBytes` не дрейфует, циклы эвикции терминируются, счётчики `mirrored/failed` не уходят в минус, блобы освобождаются при ошибке put дока. DI после удаления `EnvironmentModule` из `mcp.module.ts` корректен (`EnvironmentService` там больше не используется). ### Обязательно до мерджа - **[warning][test-coverage] Добавить тесты `resolveInternalFilePath` на accept-путь absolute-URL/scheme и backslash** — [`packages/mcp/src/lib/internal-file-urls.ts:61-87`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/packages/mcp/src/lib/internal-file-urls.ts#L61-L87). Тесты покрывают только `..` и `%2e/%2f`-reject. Не зафиксирован неочевид��ый accept-путь: `http://evil.com/api/files/x/y.png` отбрасывает хост и возвращает `/files/x/y.png` (это безопасно — baseURL axios на loopback, хост игнорируется, реального SSRF нет), но именно отбрасывание хоста ничем не закреплено. Так как это единственная защита от SSRF/traversal для content-controlled `src`, будущий рефакторинг (например, переход на prefix-only проверку) может молча открыть обход без падающего теста. _Fix:_ добавить кейсы — absolute-URL с чужим хостом резолвится в `/files/x/y.png`; `/api/files\..\auth\whoami` бросает; `https://evil.com/api/auth/whoami` бросает. - **[suggestion][documentation] Согласовать MCP-результат `stash_page` (только `resource_link`) с задокументированной формой `{ uri, size, sha256, images }`** — [`packages/mcp/src/index.ts:414-433`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/packages/mcp/src/index.ts#L414-L433). Описание в `tool-specs.ts` и оба README обещают `Returns { uri, size, sha256, images:{mirrored, failed} }`. Это верно для in-app пути (`ai-chat-tools.service.ts` отдаёт полный объект), но MCP-транспорт возвращает только `resource_link` с `uri/name/mimeType/size` — без `sha256` и `images`. Радиус мал (sha256 доступен как ETag блоба, ссылка — основной payload), поэтому non-blocking. _Fix:_ либо добавить `structuredContent: { uri, sha256, size, images }` рядом с `resource_link` в `index.ts`, либо смягчить общее описание (по MCP возвращается `resource_link`, а sha256 — это ETag блоба). - **[suggestion][test-coverage] Покрыть catch-ветку `new URL`** — [`packages/mcp/src/lib/internal-file-urls.ts:72-78`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/packages/mcp/src/lib/internal-file-urls.ts#L72-L78). Ветка «Invalid internal file src» достижима (например, `http://[`), но не тестируется. _Fix:_ добавить `assert.throws` на malformed-src. - **[suggestion][test-coverage] Проверить one-shot-warn дедуп в `getPositiveIntEnv`** — [`environment.service.ts:360-375`](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/src/commit/aff58646d16e/apps/server/src/integrations/environment/environment.service.ts#L360-L375). Тесты проверяют fallback-значение, но не то, что `logger.warn` срабатывает один раз на ключ (ради чего и заведён `invalidPositiveIntWarned`). _Fix:_ кейс с invalid env, спай на `logger.warn`, два вызова геттера, ожидание ровно одного warn. ### Покрытие тестами Покрытие сильное. Полноценно протестированы: `SandboxStore` (per-blob/total caps, FIFO-эвикция, lazy expiry, sweep, `has/remove`, `putAndLink`/`asSink` round-trip, учёт байт), `SandboxController` (200 с ETag/Cache-Control/nosniff/CSP, inline-vs-attachment по mime, 404 на non-UUID/missing, 304 для quoted/bare/`W/`/списка/`*`), `stashPage` (зеркалирование+rewrite, dedup, внешний CDN не трогается, failed-image без аборта, not-configured guard, и все три ветки FIFO-реконсиляции, включая doc-put-throws), геттеры env (fallback). Незакрытые места — только три перечисленных выше; главное из них — security-critical accept-путь `resolveInternalFilePath`.
vvzvlad added 1 commit 2026-06-28 20:58:51 +03:00
Mandatory (test-coverage):
- internal-file-urls.test: pin the SSRF/traversal ACCEPT path of
  resolveInternalFilePath (the sole guard for content-controlled `src`): an
  absolute/protocol-relative URL has its foreign host dropped and only an
  /api/files/ pathname survives (http://evil.com/api/files/x/y.png -> /files/x/y.png),
  while a host-dropped path that escapes /api/files/ (https://evil.com/api/auth/whoami)
  or a backslash-traversal (/api/files\..\auth\whoami) is rejected. Locks the
  behavior so a future prefix-only refactor cannot silently open a bypass.

Suggestions:
- index.ts: the stash_page MCP tool now returns structuredContent
  { uri, sha256, size, images } alongside the resource_link, so the MCP output
  matches the documented shape (clients get the blob's sha256/ETag and the
  mirror counts, not just the link). No outputSchema registered. Rebuilt build/.
- new stash-page-mcp-result.test: server round-trip via InMemoryTransport asserts
  both the resource_link and the structuredContent mirror.
- internal-file-urls.test: cover the new URL parse-failure catch branch
  (http://[ -> "Invalid internal file src").
- environment.service.spec: assert getPositiveIntEnv warns once per key and
  independently across keys (the invalidPositiveIntWarned dedup).

Tests: packages/mcp 383 pass; apps/server sandbox/environment/mcp 235 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad merged commit 6daa10db67 into develop 2026-06-28 21:01:14 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#250