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>
266 lines
9.0 KiB
TypeScript
266 lines
9.0 KiB
TypeScript
import { SandboxController } from './sandbox.controller';
|
|
import { SandboxEntry } from './sandbox.store';
|
|
|
|
// Capturing fake of the FastifyReply surface the controller uses:
|
|
// status()/header()/headers()/send(), all chainable.
|
|
function makeRes() {
|
|
const sent: { status: number; headers: Record<string, any>; body: any } = {
|
|
status: 200,
|
|
headers: {},
|
|
body: undefined,
|
|
};
|
|
const res: any = {
|
|
status(code: number) {
|
|
sent.status = code;
|
|
return res;
|
|
},
|
|
header(key: string, value: any) {
|
|
sent.headers[key.toLowerCase()] = value;
|
|
return res;
|
|
},
|
|
headers(obj: Record<string, any>) {
|
|
for (const k of Object.keys(obj)) sent.headers[k.toLowerCase()] = obj[k];
|
|
return res;
|
|
},
|
|
send(body?: any) {
|
|
sent.body = body;
|
|
return res;
|
|
},
|
|
_sent: sent,
|
|
};
|
|
return res;
|
|
}
|
|
|
|
function makeReq(headers: Record<string, any> = {}) {
|
|
return { headers } as any;
|
|
}
|
|
|
|
// A syntactically valid v4 UUID (version nibble 4, variant nibble 8). The
|
|
// shared `uuid` validator is stricter than a bare hex-shape regex, so the id
|
|
// must carry a real version/variant.
|
|
const VALID_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
|
|
function entry(buf: Buffer, mime: string, sha256: string): SandboxEntry {
|
|
return { buf, mime, sha256, expiresAt: Date.now() + 60_000 };
|
|
}
|
|
|
|
describe('SandboxController', () => {
|
|
it('serves 200 with body, Content-Type, Content-Length and sha256 ETag', async () => {
|
|
const buf = Buffer.from('{"ok":true}', 'utf8');
|
|
const sha = 'a'.repeat(64);
|
|
const store = { get: jest.fn().mockReturnValue(entry(buf, 'application/json', sha)) };
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get(VALID_ID, makeReq(), res);
|
|
|
|
expect(store.get).toHaveBeenCalledWith(VALID_ID);
|
|
expect(res._sent.status).toBe(200);
|
|
expect(res._sent.headers['content-type']).toBe('application/json');
|
|
expect(res._sent.headers['content-length']).toBe(buf.length);
|
|
expect(res._sent.headers['etag']).toBe(`"${sha}"`);
|
|
expect(res._sent.body).toBe(buf);
|
|
});
|
|
|
|
it('returns 404 for a missing/expired blob', async () => {
|
|
const store = { get: jest.fn().mockReturnValue(undefined) };
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get(VALID_ID, makeReq(), res);
|
|
|
|
expect(res._sent.status).toBe(404);
|
|
expect(res._sent.body).toBeUndefined();
|
|
});
|
|
|
|
it('returns 404 for a non-UUID id WITHOUT touching the store (anti-traversal)', async () => {
|
|
const store = { get: jest.fn() };
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get('../../etc/passwd', makeReq(), res);
|
|
|
|
expect(store.get).not.toHaveBeenCalled();
|
|
expect(res._sent.status).toBe(404);
|
|
});
|
|
|
|
it('returns 304 (no body) when If-None-Match matches the ETag', async () => {
|
|
const sha = 'b'.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': `"${sha}"` }), res);
|
|
|
|
expect(res._sent.status).toBe(304);
|
|
expect(res._sent.body).toBeUndefined();
|
|
expect(res._sent.headers['etag']).toBe(`"${sha}"`);
|
|
});
|
|
|
|
it('accepts a bare (unquoted) sha256 in If-None-Match too', async () => {
|
|
const sha = 'c'.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': sha }), res);
|
|
|
|
expect(res._sent.status).toBe(304);
|
|
});
|
|
|
|
it('serves 200 when If-None-Match does NOT match', async () => {
|
|
const sha = 'd'.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': '"stale"' }), res);
|
|
|
|
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);
|
|
});
|
|
|
|
it('emits Cache-Control alongside ETag on the 304 branch', async () => {
|
|
const sha = '3'.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': `"${sha}"` }), res);
|
|
|
|
expect(res._sent.status).toBe(304);
|
|
expect(res._sent.headers['cache-control']).toMatch(
|
|
/^private, max-age=\d+, immutable$/,
|
|
);
|
|
});
|
|
|
|
it('sets nosniff + restrictive CSP and serves an allowlisted image inline', async () => {
|
|
const sha = '4'.repeat(64);
|
|
const store = {
|
|
get: jest.fn().mockReturnValue(entry(Buffer.from('x'), 'image/png', sha)),
|
|
};
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get(VALID_ID, makeReq(), res);
|
|
|
|
expect(res._sent.status).toBe(200);
|
|
expect(res._sent.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res._sent.headers['content-security-policy']).toBe(
|
|
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
|
);
|
|
expect(res._sent.headers['content-disposition']).toBe('inline');
|
|
});
|
|
|
|
it('forces an SVG to download (attachment) while keeping nosniff + CSP', async () => {
|
|
const sha = '5'.repeat(64);
|
|
const store = {
|
|
get: jest.fn().mockReturnValue(entry(Buffer.from('<svg/>'), 'image/svg+xml', sha)),
|
|
};
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get(VALID_ID, makeReq(), res);
|
|
|
|
expect(res._sent.status).toBe(200);
|
|
expect(res._sent.headers['content-disposition']).toBe('attachment');
|
|
expect(res._sent.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res._sent.headers['content-security-policy']).toBe(
|
|
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
|
);
|
|
});
|
|
|
|
it('forces text/html to download (attachment) while keeping nosniff + CSP', async () => {
|
|
const sha = '6'.repeat(64);
|
|
const store = {
|
|
get: jest
|
|
.fn()
|
|
.mockReturnValue(entry(Buffer.from('<h1>x</h1>'), 'text/html', sha)),
|
|
};
|
|
const controller = new SandboxController(store as any);
|
|
const res = makeRes();
|
|
|
|
await controller.get(VALID_ID, makeReq(), res);
|
|
|
|
expect(res._sent.status).toBe(200);
|
|
expect(res._sent.headers['content-disposition']).toBe('attachment');
|
|
expect(res._sent.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res._sent.headers['content-security-policy']).toBe(
|
|
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
|
);
|
|
});
|
|
});
|