Mandatory (test-coverage): - internal-file-urls.test: pin the SSRF/traversal ACCEPT path of resolveInternalFilePath (the sole guard for content-controlled `src`): an absolute/protocol-relative URL has its foreign host dropped and only an /api/files/ pathname survives (http://evil.com/api/files/x/y.png -> /files/x/y.png), while a host-dropped path that escapes /api/files/ (https://evil.com/api/auth/whoami) or a backslash-traversal (/api/files\..\auth\whoami) is rejected. Locks the behavior so a future prefix-only refactor cannot silently open a bypass. Suggestions: - index.ts: the stash_page MCP tool now returns structuredContent { uri, sha256, size, images } alongside the resource_link, so the MCP output matches the documented shape (clients get the blob's sha256/ETag and the mirror counts, not just the link). No outputSchema registered. Rebuilt build/. - new stash-page-mcp-result.test: server round-trip via InMemoryTransport asserts both the resource_link and the structuredContent mirror. - internal-file-urls.test: cover the new URL parse-failure catch branch (http://[ -> "Invalid internal file src"). - environment.service.spec: assert getPositiveIntEnv warns once per key and independently across keys (the invalidPositiveIntWarned dedup). Tests: packages/mcp 383 pass; apps/server sandbox/environment/mcp 235 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { EnvironmentService } from './environment.service';
|
|
|
|
// Direct instantiation with a stub ConfigService, mirroring the rest of these
|
|
// unit specs.
|
|
describe('EnvironmentService', () => {
|
|
let service: EnvironmentService;
|
|
|
|
beforeEach(() => {
|
|
service = new EnvironmentService(
|
|
{} as any, // configService
|
|
);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('getSandboxTtlMs', () => {
|
|
// ConfigService stub: get(key, def) returns the configured value for the key
|
|
// (falling back to def), matching the @nestjs/config contract the service
|
|
// calls with (key, default).
|
|
const build = (sandboxTtl?: string) =>
|
|
new EnvironmentService({
|
|
get: (key: string, def?: string) =>
|
|
key === 'SANDBOX_TTL_MS' ? (sandboxTtl ?? def) : def,
|
|
} as any);
|
|
|
|
it.each(['0', '-5', 'abc'])(
|
|
'falls back to the 3600000 default for invalid value %s',
|
|
(value) => {
|
|
expect(build(value).getSandboxTtlMs()).toBe(3_600_000);
|
|
},
|
|
);
|
|
|
|
it('returns the parsed value for a valid positive integer', () => {
|
|
expect(build('120000').getSandboxTtlMs()).toBe(120_000);
|
|
});
|
|
|
|
it('uses the 3600000 default when SANDBOX_TTL_MS is unset', () => {
|
|
expect(build(undefined).getSandboxTtlMs()).toBe(3_600_000);
|
|
});
|
|
});
|
|
|
|
// The three byte caps share the same getPositiveIntEnv() helper as the TTL,
|
|
// so a non-integer / non-positive value ('0'/'-5'/'abc') falls back to the
|
|
// documented default and a valid positive integer is returned parsed. Note
|
|
// parseInt truncates '1.5' -> 1 (a valid positive integer), so that value is
|
|
// accepted, not rejected — same as the pre-existing TTL getter.
|
|
describe.each([
|
|
{
|
|
name: 'getSandboxMaxBytes',
|
|
key: 'SANDBOX_MAX_BYTES',
|
|
def: 8_388_608,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxBytes(),
|
|
},
|
|
{
|
|
name: 'getSandboxMaxImageBytes',
|
|
key: 'SANDBOX_MAX_IMAGE_BYTES',
|
|
def: 20_971_520,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxImageBytes(),
|
|
},
|
|
{
|
|
name: 'getSandboxMaxTotalBytes',
|
|
key: 'SANDBOX_MAX_TOTAL_BYTES',
|
|
def: 134_217_728,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxTotalBytes(),
|
|
},
|
|
])('$name', ({ key, def, getter }) => {
|
|
// ConfigService stub: get(k, d) returns the configured value for THIS cap's
|
|
// key (falling back to d), and the default for every other key.
|
|
const build = (value?: string) =>
|
|
new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === key ? (value ?? d) : d,
|
|
} as any);
|
|
|
|
it.each(['0', '-5', 'abc'])(
|
|
`falls back to the ${def} default for invalid value %s`,
|
|
(value) => {
|
|
expect(getter(build(value))).toBe(def);
|
|
},
|
|
);
|
|
|
|
it('returns the parsed value for a valid positive integer', () => {
|
|
expect(getter(build('4096'))).toBe(4096);
|
|
});
|
|
|
|
it('truncates a non-integer like "1.5" to 1 via parseInt (not rejected)', () => {
|
|
expect(getter(build('1.5'))).toBe(1);
|
|
});
|
|
|
|
it(`uses the ${def} default when the env is unset`, () => {
|
|
expect(getter(build(undefined))).toBe(def);
|
|
});
|
|
});
|
|
|
|
// getPositiveIntEnv keeps a one-shot `invalidPositiveIntWarned` set so a bad
|
|
// value is logged ONCE per key (not on every getter call, which the sandbox
|
|
// hits per-put). These tests pin that dedup so a regression to per-call logging
|
|
// would fail loudly.
|
|
describe('invalid-value warn dedup', () => {
|
|
it('warns only once per key across repeated getter calls', () => {
|
|
const service = new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === 'SANDBOX_MAX_TOTAL_BYTES' ? '-5' : d,
|
|
} as any);
|
|
const warnSpy = jest
|
|
.spyOn((service as any).logger, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
|
|
service.getSandboxMaxTotalBytes();
|
|
service.getSandboxMaxTotalBytes();
|
|
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('warns independently per key (dedup is per-key, not global)', () => {
|
|
// Two DIFFERENT SANDBOX_* keys are both invalid -> each warns once, so two
|
|
// warns total. This proves the dedup set is keyed, not a single global flag.
|
|
const service = new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === 'SANDBOX_MAX_BYTES' || k === 'SANDBOX_MAX_TOTAL_BYTES'
|
|
? '-5'
|
|
: d,
|
|
} as any);
|
|
const warnSpy = jest
|
|
.spyOn((service as any).logger, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
|
|
service.getSandboxMaxBytes();
|
|
service.getSandboxMaxTotalBytes();
|
|
|
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('getSandboxPublicUrl', () => {
|
|
// Stub that resolves BOTH keys the public-url logic consults.
|
|
const build = (vals: { sandboxUrl?: string; appUrl?: string }) =>
|
|
new EnvironmentService({
|
|
get: (key: string, def?: string) =>
|
|
key === 'SANDBOX_PUBLIC_URL'
|
|
? (vals.sandboxUrl ?? def)
|
|
: key === 'APP_URL'
|
|
? (vals.appUrl ?? def)
|
|
: def,
|
|
} as any);
|
|
|
|
it('uses SANDBOX_PUBLIC_URL and trims a trailing slash', () => {
|
|
expect(
|
|
build({ sandboxUrl: 'https://docs.example.com/' }).getSandboxPublicUrl(),
|
|
).toBe('https://docs.example.com');
|
|
});
|
|
|
|
it('falls back to APP_URL (origin) when SANDBOX_PUBLIC_URL is unset', () => {
|
|
expect(
|
|
build({ appUrl: 'https://app.example.com' }).getSandboxPublicUrl(),
|
|
).toBe('https://app.example.com');
|
|
});
|
|
});
|
|
});
|