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:
@@ -121,4 +121,70 @@ describe('SandboxController', () => {
|
||||
|
||||
expect(res._sent.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 304 for a wildcard "*" If-None-Match', async () => {
|
||||
const sha = 'e'.repeat(64);
|
||||
const store = {
|
||||
get: jest.fn().mockReturnValue(entry(Buffer.from('x'), 'application/json', sha)),
|
||||
};
|
||||
const controller = new SandboxController(store as any);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.get(VALID_ID, makeReq({ 'if-none-match': '*' }), res);
|
||||
|
||||
expect(res._sent.status).toBe(304);
|
||||
});
|
||||
|
||||
it('returns 304 for a weak validator W/"<sha>"', async () => {
|
||||
const sha = 'f'.repeat(64);
|
||||
const store = {
|
||||
get: jest.fn().mockReturnValue(entry(Buffer.from('x'), 'application/json', sha)),
|
||||
};
|
||||
const controller = new SandboxController(store as any);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.get(VALID_ID, makeReq({ 'if-none-match': `W/"${sha}"` }), res);
|
||||
|
||||
expect(res._sent.status).toBe(304);
|
||||
});
|
||||
|
||||
it('returns 304 when a comma-separated If-None-Match list contains the sha', async () => {
|
||||
const sha = '1'.repeat(64);
|
||||
const store = {
|
||||
get: jest.fn().mockReturnValue(entry(Buffer.from('x'), 'application/json', sha)),
|
||||
};
|
||||
const controller = new SandboxController(store as any);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.get(
|
||||
VALID_ID,
|
||||
makeReq({ 'if-none-match': `"other", "${sha}"` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res._sent.status).toBe(304);
|
||||
});
|
||||
|
||||
it('sets a private, immutable Cache-Control with a max-age within the TTL on 200', async () => {
|
||||
const sha = '2'.repeat(64);
|
||||
// Known TTL: ~30s out, so the floored max-age must land within [0, 60].
|
||||
const e: SandboxEntry = {
|
||||
buf: Buffer.from('x'),
|
||||
mime: 'application/json',
|
||||
sha256: sha,
|
||||
expiresAt: Date.now() + 30_000,
|
||||
};
|
||||
const store = { get: jest.fn().mockReturnValue(e) };
|
||||
const controller = new SandboxController(store as any);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.get(VALID_ID, makeReq(), res);
|
||||
|
||||
expect(res._sent.status).toBe(200);
|
||||
const cc = res._sent.headers['cache-control'] as string;
|
||||
expect(cc).toMatch(/^private, max-age=\d+, immutable$/);
|
||||
const maxAge = Number(cc.match(/max-age=(\d+)/)![1]);
|
||||
expect(maxAge).toBeGreaterThanOrEqual(0);
|
||||
expect(maxAge).toBeLessThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user