fix(sandbox): address PR #250 review — SSRF guard, eviction safety, cleanup (#243)

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>
This commit is contained in:
claude_code
2026-06-28 18:02:46 +03:00
parent 2fe4ca8537
commit 6eb335d5e3
24 changed files with 708 additions and 97 deletions
@@ -1,9 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
private readonly logger = new Logger(EnvironmentService.name);
// One-shot guard so an invalid SANDBOX_TTL_MS is warned about once, not on
// every getSandboxTtlMs() call (which runs per blob put).
private sandboxTtlWarned = false;
constructor(private configService: ConfigService) {}
getNodeEnv(): string {
@@ -348,12 +353,25 @@ export class EnvironmentService {
}
// Blob time-to-live. Default 1h. The unguessable UUID + this short TTL + TLS
// are the whole capability model (no tokens).
// are the whole capability model (no tokens). A non-positive or non-integer
// value would make every blob expire instantly (silent 404s), so reject it and
// fall back to the 1h default (warned about once to avoid per-put log spam).
getSandboxTtlMs(): number {
return parseInt(
const parsed = parseInt(
this.configService.get<string>('SANDBOX_TTL_MS', '3600000'),
10,
);
if (!Number.isInteger(parsed) || parsed <= 0) {
if (!this.sandboxTtlWarned) {
this.sandboxTtlWarned = true;
this.logger.warn(
`Invalid SANDBOX_TTL_MS (must be a positive integer); ` +
`falling back to the 3600000 ms default`,
);
}
return 3_600_000;
}
return parsed;
}
// Per-blob cap for non-image blobs (the serialized document). Default 8 MiB.