Skip to content

web: sandbox CSP builder library (lib/sandbox-csp.ts) #1558

Closed

Description

@cliffhall

Summary

Add a pure CSP-builder library at clients/web/src/lib/sandbox-csp.ts: validate app-supplied _meta.ui.csp requests, filter to safe sources, and wrap untrusted widget HTML in a fixed shell so the policy is always the first <head> child.

Why

The inspector plumbs _meta.ui.csp through today but never enforces it — a widget developer gets a misleading "works here". Enforcement needs a typed, unit-testable library: default-src 'none' catch-all, form-action 'none', source-allowlist filtering, attribute escaping. This issue is the standalone library only; wiring into the app bridge is a follow-up issue.

Reference implementation (PR #1510)

Re-implement informed by these changes at 33fac3f:

Depends on

Nothing — Wave 1 foundation, parallel-safe. Consumed later by the bridge-factory CSP-enforcement issue.

Notes

  • Pure functions, no DOM — keep it that way so the unit tests stay trivial.
  • Coverage gate ≥90 on all four dimensions.

Part of the PR #1510 decomposition (see tracking issue).

Activity

  1. self-assigned this
    on Jul 1, 2026
  2. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    Smoke test & AGENTS.md audit — PR #1588

    Reviewed clients/web/src/lib/sandbox-csp.ts on branch v2/1558-sandbox-csp (HEAD cb54de5).

    Smoke method

    sandbox-csp.ts is a pure, dependency-free library (its only import is a import type from @modelcontextprotocol/ext-apps/app-bridge), so no Playwright/DOM is needed. I wrote a standalone tsx script that imports the real exports (buildSandboxCspPolicy, approveCspSources, wrapSandboxedHtml, escapeHtmlAttr, SAFE_CSP_SOURCE) and fed representative app-supplied inputs: valid https/wss sources (incl. a port), a bare *, two injection/breakout attempts, and an img/<script> inner HTML. Real output below.

    Real output

    approveCspSources — drops the injection/breakout/javascript: sources (each warned), keeps the safe ones incl. the broad-but-syntactically-safe *:

    [mcp-app sandbox] dropping unsafe CSP source: https://evil.com; script-src *
    [mcp-app sandbox] dropping unsafe CSP source: https://evil.com"><script>alert(1)</script>
    [mcp-app sandbox] dropping unsafe CSP source: javascript:alert(1)
    {
      "connectDomains": ["https://api.example.com", "wss://realtime.example.com:8443"],
      "resourceDomains": ["https://cdn.example.com", "*"],
      "frameDomains": ["https://embed.example.com"]
    }
    

    buildSandboxCspPolicy — generated CSP string (resourceDomains feeds script/style/img/font/media; catch-alls locked to 'none', base-uri 'self'):

    default-src 'none'; connect-src https://api.example.com wss://realtime.example.com:8443; script-src 'unsafe-inline' https://cdn.example.com *; style-src 'unsafe-inline' https://cdn.example.com *; img-src https://cdn.example.com *; font-src https://cdn.example.com *; media-src https://cdn.example.com *; frame-src https://embed.example.com; base-uri 'self'; form-action 'none'; object-src 'none'; worker-src 'none'
    

    wrapSandboxedHtml — CSP <meta> is the literal first <head> child, ahead of the untrusted body bytes:

    <!DOCTYPE html><html><head><meta http-equiv="Content-Security-Policy" content="default-src &#39;none&#39;; connect-src https://api.example.com wss://realtime.example.com:8443; script-src &#39;unsafe-inline&#39; https://cdn.example.com *; style-src &#39;unsafe-inline&#39; https://cdn.example.com *; img-src https://cdn.example.com *; font-src https://cdn.example.com *; media-src https://cdn.example.com *; frame-src https://embed.example.com; base-uri &#39;self&#39;; form-action &#39;none&#39;; object-src &#39;none&#39;; worker-src &#39;none&#39;"><meta charset="utf-8"></head><body><h1>Widget</h1><script>console.log('hi')</script><img src="https://cdn.example.com/a.png"></body></html>

    A breakout policy value is attribute-encoded and cannot escape the content="…" attribute:

    <meta http-equiv="Content-Security-Policy" content="x&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;">

    SAFE_CSP_SOURCE accept/reject decisions:

    ACCEPT  "https://api.example.com"          ACCEPT  "*"              ACCEPT  "data:"
    ACCEPT  "wss://realtime.example.com:8443"  ACCEPT  "*.example.com"
    REJECT  "https://a.com; script-src *"      REJECT  'https://a.com" onload='   REJECT  "javascript:alert(1)//;x"
    

    Behaviour matches the documented contract exactly.

    AGENTS.md compliance

    • Mantine / React: N/A — pure non-React library, no components or styling.
    • TypeScript: Clean. No any, no eslint-disable, no @ts-* suppressions anywhere in the source or test diff. Strongly typed against McpUiResourceCsp. The compile-time exhaustiveCspKeys<const T>() guard is a nice idiom — it forces a never (and a compile error) if the upstream McpUiResourceCsp ever gains a new domain key, so a new grant category can't be silently dropped by approveCspSources. (The one assertion in the test, as unknown as string[], is a deliberate double-assertion to exercise the non-array guard — not any.)
    • Coverage: sandbox-csp.ts reports 100 / 100 / 100 / 100 (Stmts / Branch / Funcs / Lines) via cd clients/web && npm run test:coverage, well above the ≥90 gate. Confirmed the PR adds src/lib/sandbox-csp.{ts,tsx} to the coverage include in vite.config.ts (listed by file, not a src/lib/** glob, so untested legacy siblings aren't pulled under the gate), so the file is gate-enforced — and the full coverage run exited 0.

    Verdict

    PASS. Smoke test produces correct, injection-safe output for every representative input; AGENTS.md compliant (Mantine N/A, no any/suppressions, 100% gate-enforced coverage). No violations found.

    Custom smoke script (tsx, not committed)
    import {
      SAFE_CSP_SOURCE,
      approveCspSources,
      buildSandboxCspPolicy,
      wrapSandboxedHtml,
      escapeHtmlAttr,
    } from "clients/web/src/lib/sandbox-csp";
    
    const line = (s = "") => console.log(s);
    const rule = (t: string) => line(`\n===== ${t} =====`);
    
    const requested = {
      connectDomains: ["https://api.example.com", "wss://realtime.example.com:8443"],
      resourceDomains: [
        "https://cdn.example.com",
        "*",
        'https://evil.com; script-src *',
        'https://evil.com"><script>alert(1)</script>',
      ],
      frameDomains: ["https://embed.example.com"],
      baseUriDomains: ["javascript:alert(1)"],
    };
    
    rule("approveCspSources");
    const approved = approveCspSources(requested as never);
    console.log(JSON.stringify(approved, null, 2));
    
    rule("buildSandboxCspPolicy");
    const policy = buildSandboxCspPolicy(approved);
    line(policy);
    
    rule("wrapSandboxedHtml");
    line(wrapSandboxedHtml(
      `<h1>Widget</h1><script>console.log('hi')</script><img src="https://cdn.example.com/a.png">`,
      policy,
    ));
    
    rule("wrapSandboxedHtml (breakout policy)");
    line(wrapSandboxedHtml("<p>x</p>", `x"><script>alert(1)</script>`));
    
    rule("escapeHtmlAttr");
    line(escapeHtmlAttr(`a&b"c'd<e>f`));
    
    rule("SAFE_CSP_SOURCE decisions");
    for (const c of [
      "https://api.example.com",
      "wss://realtime.example.com:8443",
      "*",
      "*.example.com",
      "data:",
      "https://a.com; script-src *",
      'https://a.com" onload=',
      "javascript:alert(1)//;x",
    ]) line(`${SAFE_CSP_SOURCE.test(c) ? "ACCEPT" : "REJECT"}  ${JSON.stringify(c)}`);
  3. cliffhall commented on Jul 2, 2026

    @cliffhall
    MemberAuthor

    🔗 Implemented by PR #1588. Its code is already integrated into the Wave 1 rollup branch 1579-wave-1 (see rollup PR #1601 / verification #1600). #1588 currently targets v2/main and will auto-show as Merged once the rollup lands in v2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.

  4. added a commit that references this issue on Jul 2, 2026
    3de93b9
  5. added a commit that references this issue on Jul 28, 2026
    77e57e0
  6. added this to the v2.0.0 milestone on Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

v2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions