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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user