fix(sandbox): address PR #250 follow-up review — XSS hardening, eviction reconcile, doc sync (#243)

Security (must-fix):
- sandbox.controller: the anonymous GET /api/sb/:id response now sets
  X-Content-Type-Options: nosniff, a restrictive CSP, and Content-Disposition=
  attachment for any mime outside a raster-image allowlist (png/jpeg/gif/webp/
  avif). entry.mime is attacker-controlled, so an evil.svg/evil.html could
  otherwise execute script inline on the Docmost origin (stored XSS). Mirrors
  the public attachment route's hardening.

Stability:
- client.stashPage: reconcile mirrors AFTER the final document put, not only
  before it. The doc blob is the newest entry and FIFO eviction drops the
  oldest = this stash's own images, so the stored doc could reference an
  evicted blob (consumer 404) and over-report images.mirrored. A bounded loop
  now reverts doc-put-evicted mirrors, drops the stale doc blob, and re-puts
  until stable. Regenerated packages/mcp/build/.
- sandbox.controller: emit Cache-Control on the 304 branch too (ttlSeconds is
  computed before the conditional check).

Docs:
- Bump the MCP tool count 39 -> 40 across all READMEs and AGENTS.md (the
  registry now exposes exactly 40 tools).

Refactor:
- SandboxStore.asSink() centralizes the {put,has,evict} sink + uri<->id
  mapping; the embedded-MCP and in-app agent-tools wiring sites share it.

Tests:
- security headers (inline vs attachment, nosniff, CSP), 304 Cache-Control,
  putAndLink URL form, has()/remove(), asSink() round-trip, getSandboxPublicUrl
  (trailing-slash trim + APP_URL fallback), and a stash test where the doc put
  itself evicts a mirrored image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-28 19:08:06 +03:00
parent 6eb335d5e3
commit 8842bc8bf3
15 changed files with 371 additions and 87 deletions
@@ -9,6 +9,18 @@ import { SANDBOX_ROUTE_SEGMENT } from './sandbox.constants';
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
// 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.
*
@@ -22,6 +34,12 @@ const UUID_RE =
* 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 {
@@ -52,17 +70,32 @@ export class SandboxController {
// body — the original bug this whole channel exists to fix.
const etag = `"${entry.sha256}"`;
// 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).send();
return;
}
// 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.
@@ -72,8 +105,11 @@ export class SandboxController {
'Content-Type': entry.mime,
'Content-Length': entry.buf.length,
ETag: etag,
// Capability URL — keep it out of shared caches; immutable for its TTL.
'Cache-Control': `private, max-age=${ttlSeconds}, immutable`,
'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);
}