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>
131 lines
5.1 KiB
TypeScript
131 lines
5.1 KiB
TypeScript
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
|
import { validate as isValidUUID } from 'uuid';
|
|
import { SandboxStore } from './sandbox.store';
|
|
import { SANDBOX_ROUTE_SEGMENT } from './sandbox.constants';
|
|
|
|
// MIME types safe to render inline in a browser. SVG is deliberately EXCLUDED
|
|
// (it can carry script), as are text/html and the JSON document blob — anything
|
|
// not on this list is served as an attachment so an attacker-controlled mime can
|
|
// never execute script on this origin (the route is anonymous + same-origin).
|
|
const INLINE_SAFE_MIME = new Set([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/gif',
|
|
'image/webp',
|
|
'image/avif',
|
|
]);
|
|
|
|
/**
|
|
* Anonymous read endpoint for the in-RAM blob sandbox.
|
|
*
|
|
* Mounted under the global `/api` prefix as `GET /api/sb/:id`. It carries NO
|
|
* `@UseGuards(JwtAuthGuard)`, so — exactly like the public attachment route
|
|
* `GET /api/files/public/...` — it is exempt from Docmost session auth. The
|
|
* route is ALSO listed in the workspace-resolution preHandler's excludedPaths
|
|
* in main.ts so a request from a remote consumer (which carries no workspace
|
|
* host) is not rejected with "Workspace not found".
|
|
*
|
|
* It only ever serves blobs looked up from the SandboxStore by a validated
|
|
* UUID; `:id` is never used as a filesystem path, so there is no traversal
|
|
* surface. Never returns tokens, never 401s.
|
|
*
|
|
* Anti-XSS hardening mirrors the public attachment route: every response sets
|
|
* `X-Content-Type-Options: nosniff` and a restrictive CSP, and serves any mime
|
|
* NOT on the inline-safe allowlist (svg/html/the JSON document blob) as an
|
|
* attachment, so an attacker-controlled `entry.mime` can never execute script
|
|
* on this same-origin anonymous route.
|
|
*/
|
|
@Controller(SANDBOX_ROUTE_SEGMENT)
|
|
export class SandboxController {
|
|
constructor(private readonly store: SandboxStore) {}
|
|
|
|
@Get(':id')
|
|
async get(
|
|
@Param('id') id: string,
|
|
@Req() req: FastifyRequest,
|
|
@Res() res: FastifyReply,
|
|
): Promise<void> {
|
|
// Validate `:id` as a real UUID via the shared `uuid` validator (same as the
|
|
// attachment routes). This is anti-traversal / input hygiene (so `:id` can
|
|
// never be a path like `../...`), NOT authorization — the capability is the
|
|
// unguessable id itself plus the short TTL plus TLS. A non-UUID id (including
|
|
// any traversal attempt) → 404 before touching the store; no stack trace
|
|
// leaks out.
|
|
if (!isValidUUID(id)) {
|
|
res.status(404).send();
|
|
return;
|
|
}
|
|
|
|
const entry = this.store.get(id);
|
|
if (!entry) {
|
|
// Missing or expired — indistinguishable to the caller, by design.
|
|
res.status(404).send();
|
|
return;
|
|
}
|
|
|
|
// Strong validator: quoted sha256, no W/ weak prefix. Same value computed
|
|
// at put() time, so an external consumer can detect a truncated/corrupted
|
|
// body — the original bug this whole channel exists to fix.
|
|
const etag = `"${entry.sha256}"`;
|
|
|
|
// Compute freshness BEFORE the conditional check: a 304 conditional
|
|
// revalidation must not lose the Cache-Control freshness directives, or a
|
|
// revalidating client would forget how long the blob stays fresh.
|
|
const ttlSeconds = Math.max(
|
|
0,
|
|
Math.floor((entry.expiresAt - Date.now()) / 1000),
|
|
);
|
|
// Capability URL — keep it out of shared caches; immutable for its TTL.
|
|
const cacheControl = `private, max-age=${ttlSeconds}, immutable`;
|
|
|
|
// Conditional request: an exact ETag match → 304 with no body. The blob is
|
|
// immutable, so the validator is stable for the blob's whole lifetime.
|
|
if (this.ifNoneMatchMatches(req.headers['if-none-match'], entry.sha256)) {
|
|
res
|
|
.status(304)
|
|
.header('ETag', etag)
|
|
.header('Cache-Control', cacheControl)
|
|
.send();
|
|
return;
|
|
}
|
|
|
|
// Non-allowlisted mimes (svg/html/the JSON blob) are forced to download so
|
|
// an attacker-controlled mime can never run script inline on this origin.
|
|
const disposition = INLINE_SAFE_MIME.has(entry.mime)
|
|
? 'inline'
|
|
: 'attachment';
|
|
|
|
// Use @Res() + res.send(Buffer) with an explicit Content-Type so the binary
|
|
// body bypasses the global JSON response transform/serializer.
|
|
res
|
|
.status(200)
|
|
.headers({
|
|
'Content-Type': entry.mime,
|
|
'Content-Length': entry.buf.length,
|
|
ETag: etag,
|
|
'Cache-Control': cacheControl,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Content-Security-Policy':
|
|
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
|
'Content-Disposition': disposition,
|
|
})
|
|
.send(entry.buf);
|
|
}
|
|
|
|
// Accept the consumer's If-None-Match whether it sends the quoted ETag, a bare
|
|
// sha256, a weak "W/"-prefixed validator, or a comma-separated list.
|
|
private ifNoneMatchMatches(
|
|
header: string | string[] | undefined,
|
|
sha256: string,
|
|
): boolean {
|
|
if (!header) return false;
|
|
const raw = Array.isArray(header) ? header.join(',') : header;
|
|
if (raw.trim() === '*') return true;
|
|
return raw
|
|
.split(',')
|
|
.map((t) => t.trim().replace(/^W\//, '').replace(/^"|"$/g, ''))
|
|
.some((t) => t === sha256);
|
|
}
|
|
}
|