Files
gitmost/packages/mcp/test/unit/internal-file-urls.test.mjs
claude_code 6eb335d5e3 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>
2026-06-28 18:02:46 +03:00

62 lines
2.2 KiB
JavaScript

// Unit tests for the internal-file URL helpers the stash tool relies on. The
// critical case is resolveInternalFilePath, whose whole job is to REJECT a
// content-controlled `src` that tries to escape /api/files/ (SSRF / traversal)
// before it ever reaches the authenticated loopback client.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
resolveInternalFilePath,
normalizeFileUrl,
collectInternalFileNodes,
} from "../../build/lib/internal-file-urls.js";
test("resolveInternalFilePath accepts a normal internal src", () => {
assert.equal(
resolveInternalFilePath("/api/files/att-1/pic.png"),
"/files/att-1/pic.png",
);
});
test("resolveInternalFilePath rejects traversal / encoded variants (SSRF guard)", () => {
// `..` collapses to /api/auth/whoami -> outside /api/files/ -> rejected.
assert.throws(() => resolveInternalFilePath("/api/files/../auth/whoami"));
// Escapes the /api base entirely.
assert.throws(() => resolveInternalFilePath("/api/files/../../internal"));
// Percent-encoded dot -> rejected before canonicalization.
assert.throws(() => resolveInternalFilePath("/api/files/%2e%2e/x"));
// Percent-encoded slash separator -> rejected before canonicalization.
assert.throws(() => resolveInternalFilePath("/api/files/..%2fauth"));
});
test("normalizeFileUrl rewrites the bare /files/ branch and leaves /api/files/ alone", () => {
assert.equal(
normalizeFileUrl("/files/att-1/pic.png"),
"/api/files/att-1/pic.png",
);
assert.equal(
normalizeFileUrl("/api/files/att-1/pic.png"),
"/api/files/att-1/pic.png",
);
});
test("collectInternalFileNodes recurses into nested content containers", () => {
// The internal image is buried inside a callout's content array, so a
// regression on the recursion (e.g. a shallow .filter()) would miss it.
const nested = {
type: "image",
attrs: { src: "/api/files/att-9/deep.png", attachmentId: "att-9" },
};
const doc = {
type: "doc",
content: [
{
type: "callout",
content: [{ type: "paragraph", content: [nested] }],
},
],
};
const found = collectInternalFileNodes(doc);
assert.equal(found.length, 1);
assert.equal(found[0], nested);
});