fix(web): screen app-supplied sandbox permissions before granting them

The sandbox proxy's buildAllowAttribute() tests each permission key for
truthiness, so an untrusted `_meta.ui.permissions` bag could stand a
non-boolean in for a grant — `{ camera: "false" }`, a plausible way for a
server to mean "off", reads as on. Nothing screened it, because before
the `_meta.ui` fix no permissions reached the proxy at all.

Add approveSandboxPermissions alongside approveCspSources: keep only the
four keys the proxy knows, and only where the value is the spec's empty-
object marker or `true`; drop anything else (warning on a malformed
value, silently on an explicit `false`) and return undefined when nothing
survives. Both the notification and the hostCapabilities echo now carry
the approved set, so the app sees what was granted.

Also document what the listing lookup can and cannot see under
`paginatedLists`.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 21:22:14 -04:00
commit 45785a0aa6

View file

@ -17,6 +17,7 @@ import type {
} from "@modelcontextprotocol/client";
import {
approveCspSources,
approveSandboxPermissions,
buildSandboxCspPolicy,
wrapSandboxedHtml,
} from "../../../utils/sandbox-csp";
@ -324,23 +325,25 @@ export function createAppBridgeFactory(
// sendSandboxResourceReady: the view only sends ui/initialize once it
// has the HTML, so the bridge reflects this in the initialize result.
const approvedCsp = approveCspSources(meta?.csp);
// NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered
// by approveCspSources because its sources are interpolated into the
// CSP <meta> content string. `permissions` is NOT filtered here — it is
// a structured object (camera/microphone/geolocation/clipboardWrite
// booleans), and its only consumer is the sandbox proxy's
// buildAllowAttribute(), which maps each known key to a fixed
// Permissions-Policy token and ignores anything else. Untrusted values
// therefore can't reach the iframe `sandbox`/`allow` attribute as raw
// text (that layer, and the allow-same-origin strip, is owned by the
// sandbox-hardening work in #1565), so no source-style allowlist applies.
// Both halves of the app-supplied sandbox config are screened, for
// different reasons. `csp` is injection-filtered by approveCspSources
// because its sources are interpolated into the CSP <meta> content
// string. `permissions` can't reach the `sandbox`/`allow` attribute as
// raw text — the proxy maps each known key to a fixed Permissions-Policy
// token — but it tests those keys for TRUTHINESS, so a non-boolean
// (`camera: "false"`) would read as a grant; approveSandboxPermissions
// reduces the bag to the keys actually requested. The `allow-same-origin`
// strip and the rest of that layer stay owned by #1565.
const approvedPermissions = approveSandboxPermissions(
meta?.permissions,
);
hostCapabilities.sandbox = {
permissions: meta?.permissions,
permissions: approvedPermissions,
csp: approvedCsp,
};
await bridge.sendSandboxResourceReady({
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
permissions: meta?.permissions,
permissions: approvedPermissions,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));