Skip to content

Commit cb54de5

Browse files
cliffhallclaude
andcommitted
refactor(web): document + guard sandbox CSP builder per review
Address round-1 @claude review of #1588 (no behavior change to the enforced policy): - approveCspSources / buildSandboxCspPolicy: document that resourceDomains intentionally feeds script-src/style-src per the McpUiResourceCsp contract, and that approve screens injection-safety only (not breadth — a bare `*` is accepted; safe under the opaque-origin sandbox). - CSP_KEYS: replace the `satisfies` (proves listed keys valid) with an exhaustiveCspKeys() helper that ALSO fails to compile if the upstream ext-apps type gains a domain key CSP_KEYS omits, so a requested restriction can never be silently dropped. - SAFE_CSP_SOURCE: accept case-insensitive schemes (URL schemes are case-insensitive); previously over-rejected `HTTPS://…`. Adds tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
1 parent 77e57e0 commit cb54de5

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

‎clients/web/src/lib/sandbox-csp.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ describe("SAFE_CSP_SOURCE", () => {
1919
"data:",
2020
"blob:",
2121
"example.com",
22+
// Schemes are case-insensitive per the URL spec, so a capitalized or
23+
// mixed-case scheme is still a valid source (over-rejecting it would
24+
// silently drop a restriction a server asked for).
25+
"HTTPS://api.example.com",
26+
"WSS://realtime.service.com",
27+
"Https://*.cloudflare.com",
2228
])("accepts %s", (s) => {
2329
expect(SAFE_CSP_SOURCE.test(s)).toBe(true);
2430
});

‎clients/web/src/lib/sandbox-csp.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,31 @@ import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge
1010
* dropped by {@link approveCspSources}.
1111
*/
1212
export const SAFE_CSP_SOURCE =
13-
/^(?:\*|[a-z][a-z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/;
13+
/^(?:\*|[a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/;
1414

15-
const CSP_KEYS = [
15+
/**
16+
* Identity helper that pins `CSP_KEYS` to an exhaustive, valid key list at
17+
* compile time. `satisfies readonly (keyof McpUiResourceCsp)[]` alone only
18+
* proves every *listed* key is valid; it does NOT prove the list is *complete*.
19+
* The intersected conditional adds the missing half: when `CSP_KEYS` covers
20+
* every key, `keyof McpUiResourceCsp extends T[number]` holds and the parameter
21+
* type is just `T`; if the upstream ext-apps type ever gains a new domain key,
22+
* the conditional collapses the parameter type to `never` and the call fails to
23+
* compile — forcing the key to be added here rather than being silently dropped
24+
* (a vanished restriction) by {@link approveCspSources}.
25+
*/
26+
function exhaustiveCspKeys<const T extends readonly (keyof McpUiResourceCsp)[]>(
27+
keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never),
28+
): T {
29+
return keys;
30+
}
31+
32+
const CSP_KEYS = exhaustiveCspKeys([
1633
"connectDomains",
1734
"resourceDomains",
1835
"frameDomains",
1936
"baseUriDomains",
20-
] as const satisfies readonly (keyof McpUiResourceCsp)[];
37+
]);
2138

2239
/**
2340
* Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host
@@ -26,6 +43,16 @@ const CSP_KEYS = [
2643
* return value is what the host echoes back to the view via
2744
* `hostCapabilities.sandbox.csp` so the app sees what was granted, not what it
2845
* asked for.
46+
*
47+
* NOTE: this screens each source for *injection safety* only — it does NOT
48+
* bound the *breadth* of a grant. A bare `*` (and a scheme-wildcard host) is a
49+
* syntactically safe source, so `resourceDomains: ["*"]` is "approved" and maps
50+
* to `script-src 'unsafe-inline' *` (scripts from anywhere), just as
51+
* `connectDomains: ["*"]` maps to `connect-src *`. That breadth is acceptable
52+
* here because the enforced document runs in an opaque-origin sandbox with no
53+
* ambient credentials and nothing to exfiltrate beyond what the app already
54+
* received; "approved" therefore means "cannot break out of the meta
55+
* attribute," not "restrictive."
2956
*/
3057
export function approveCspSources(
3158
csp: McpUiResourceCsp | undefined,
@@ -59,6 +86,14 @@ function joinSources(list: string[] | undefined, fallback: string): string {
5986
* `style-src` carry `'unsafe-inline'` because the app's own inline code ships
6087
* with the inline-delivered HTML and has no origin to allowlist; external loads
6188
* stay restricted to `resourceDomains`.
89+
*
90+
* `resourceDomains` intentionally feeds `script-src` (and `style-src`) in
91+
* addition to `img-src`/`font-src`/`media-src`: the `McpUiResourceCsp` contract
92+
* defines it as a single "static resources" allowlist that "Maps to CSP
93+
* `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives," so
94+
* an app that lists a CDN there is granted script execution from that origin by
95+
* design. There is no narrower per-directive key in the contract; if the spec
96+
* ever splits scripts out, update the mapping here accordingly.
6297
*/
6398
export function buildSandboxCspPolicy(approved: McpUiResourceCsp): string {
6499
const resourceSrc = joinSources(approved.resourceDomains, "'none'");

0 commit comments

Comments
 (0)