ui-security-sandbox

v2026.09.25

This skill should be used when hardening an agent UI — authoring the narrowest _meta.ui.csp allowlists, the iframe sandbox and proxy-origin model, treating structuredContent as untrusted input, prompt-injection through rendered data, host-mediated navigation, secrets handling, and the pre-ship security checklist.

GitHub
Install command
npx skhub add thelobbi/ui-security-sandbox
Markdown
SKILL.md

Security model for agent-rendered UI

Your View renders data that travelled: external service → MCP server → model → host → iframe. Every hop is a place content could have been shaped by someone who is not your user. Design accordingly.

The sandbox contract

  • The View runs in a sandboxed iframe with no access to the host's DOM, cookies, localStorage, or origin.
  • postMessage is the only channel, which makes every capability enumerable and auditable. That is a feature — do not try to widen it.
  • CSP is deny-by-default. Anything not declared in _meta.ui.csp is blocked.
  • Nested frames are blocked unless frameDomains opts in (and several hosts do not support frameDomains at all).

For mcp-ui's client renderer, the sandbox.url proxy origin is what enforces isolation. Never point it at your own application origin to make something work — that hands untrusted markup your session.

Authoring CSP

Declare the narrowest possible allowlists in the resource contents _meta:

_meta: {
  ui: {
    csp: {
      connectDomains: ["https://api.example.com"],       // fetch / WebSocket
      resourceDomains: ["https://static.example.com"],   // scripts, styles, images, fonts
      frameDomains: [],                                  // nested iframes — leave empty
      baseUriDomains: [],
    },
    domain: "https://example.com",   // the origin the View is served as
  },
}

Rules:

  • One entry per real dependency. No wildcards, no https://*.
  • No CDN domains you can avoid. Inline assets with vite-plugin-singlefile or esbuild and resourceDomains shrinks to nothing.
  • connectDomains is not a convenience list. Each entry is an origin the View may exfiltrate rendered data to. Justify every one.
  • frameDomains stays empty unless a specific embed is a product requirement. Copilot ignores it entirely, so an embed-dependent design fails there anyway.
  • CSP goes in the contents _meta, not the config argument. Misplacing it produces a View that loads and then silently cannot reach the network.

Review processes (OpenAI's, and any internal one worth having) diff the declared policy against observed behavior. An over-broad allowlist is a review finding before it is an incident.

structuredContent is untrusted input

The single most important rule.

// ❌ Executes anything the upstream service put in the field.
el.innerHTML = toolResult.structuredContent.description;

// ✅ Text is text.
el.textContent = toolResult.structuredContent.description;

React's default {value} interpolation is safe. dangerouslySetInnerHTML is not, and neither is any string that reaches innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function, or a javascript: URL.

Validate the shape before rendering. A schema check at the View boundary — zod, valibot, or a hand-rolled guard — turns a whole class of malformed-payload bugs into a clean error state:

const Forecast = z.object({ city: z.string().max(120), days: z.array(Day).max(14) });
const parsed = Forecast.safeParse(toolResult.structuredContent);
if (!parsed.success) return renderError();

Bound array lengths and string lengths. An unbounded list is a denial-of-service against your own render loop.

URLs from data

Any URL that arrived in the payload is attacker-influenced.

function safeHref(raw: string): string | null {
  try {
    const u = new URL(raw);
    return u.protocol === "https:" || u.protocol === "http:" ? u.toString() : null;
  } catch { return null; }
}
  • Reject everything but http:/https: — this kills javascript:, data:, and vbscript: in one check.
  • Navigate via app.openLink({ url }) / ui/open-link, never window.open, location.assign, or top.location. Host-mediated navigation is the whole point: the host can show the destination and refuse.
  • Same rule for image src — a data: image is usually fine, an arbitrary remote origin needs to be in resourceDomains.

Prompt injection through the View

Two directions, both real:

Data → model. Whatever you pass to ui/update-model-context or ui/message enters the conversation as trusted-looking context. If it contains attacker-controlled text, you have handed an injection straight to the model. Send structured facts about what the user is looking at — IDs, counts, selections — not raw upstream prose.

// ❌ raw upstream text into model context
app.updateModelContext({ note: item.descriptionFromThirdParty });

// ✅ facts the model needs, bounded and typed
app.updateModelContext({ selectedIds: ids, view: "list", total: items.length });

Model → View. Tool arguments are model-authored. Validate them server-side before acting; never treat "the model sent it" as authorization.

Authorization lives on the server

  • The View is a rendering surface, not a trust boundary. Every state-changing action goes through a tool call the server authorizes against the authenticated user.
  • Never trust a total, price, quantity, permission flag, or ID computed in the component. Recompute server-side.
  • Make mutating tools idempotent — a View can retry, and the host can replay.
  • visibility: ["app"] hides a tool from the model. It does not authorize the caller. It is context hygiene, not access control.

Secrets

  • No API keys, tokens, or connection strings in the View bundle. The HTML is served to the host and readable by the user.
  • Do not put credentials in ui:// URIs, query strings, or initial-render-data.
  • All privileged calls proxy through the server, which holds the credential.
  • OAuth 2.1 or Entra SSO at the server boundary; anonymous auth is for local development only and must be removed before deploy.

Storage

  • localStorage in the iframe is unreliable and origin-scoped in ways you do not control. Never use it as a source of truth.
  • Widget state (setWidgetState) is ephemeral and instance-scoped. Not storage.
  • Durable data lives on your server, keyed to the authenticated user.

Pre-ship checklist

  • connectDomains and resourceDomains contain only origins actually used.
  • frameDomains empty, or a documented product requirement.
  • CSP is in the resource contents _meta.
  • No innerHTML / dangerouslySetInnerHTML / eval / new Function anywhere.
  • structuredContent schema-validated at the View boundary, with bounds.
  • Every data-derived URL passes a protocol allowlist.
  • All navigation goes through openLink / ui/open-link.
  • Model context carries facts, not raw third-party text.
  • Every mutating tool authorizes server-side and is idempotent.
  • No secrets in the bundle, the URI, or render data.
  • Anonymous auth removed; OAuth 2.1 or Entra SSO configured.
  • Resource URI bumped if the UI changed in a breaking way.

Run /ui:csp for the automated pass over the first eight.

Related

  • mcp-apps-protocol — the sandbox and CSP fields.
  • host-capability-matrix — which CSP properties each host honors.
  • m365-copilot-packaging — CORS and redirect-URI allowlists.
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

MIT

Source path

plugins/mcp-apps-studio/skills/ui-security-sandbox

Default branch

main

Latest commit

2f1269c

Tree SHA

629e050