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>
62 lines
2.2 KiB
JavaScript
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);
|
|
});
|