Claude skill cross-origin isolation failures
When a Claude MCP skill serves web content — embedded iframes, tool-generated pages, WebSocket endpoints, or HTTP APIs consumed by browser clients — the browser’s cross-origin isolation model becomes part of the attack surface. Cross-origin isolation is a coordinated set of HTTP headers and browser policies that control which origins can share memory, which cross-origin resources can load, and which origins can send credentialed requests to an API. The four patterns in SA-SKL-CSP-005 through CSP-008 describe skills that opt out of isolation protections by omission: missing COEP and COOP headers leave the door open for Spectre-class timing attacks via SharedArrayBuffer; wildcard CORS combined with credentials lets any external origin read private API responses; postMessage handlers that skip origin checks allow any embedding page to inject arbitrary commands; and ignoring Sec-Fetch-* metadata headers means the server never distinguishes a same-origin request from an attacker’s cross-site form submission. Each failure is an opt-out by default — the browser offers the protection for free, but the skill must set the header or check the value to receive it.
Cross-origin attacks on web-serving MCP skills differ from traditional web application attacks in one important way: the MCP skill is typically trusted by the Claude host and has privileged access to user data, internal APIs, and tool execution. A cross-origin attack that reaches an MCP skill endpoint is not attacking a commodity website — it is attacking a privileged automation agent. An attacker who can read a private API response from a Claude skill, inject a command into a postMessage handler, or exploit a SharedArrayBuffer timing side-channel in a skill-served page has direct access to whatever the skill can do: reading files, making API calls, executing code, and modifying stored data. The impact of cross-origin isolation failures in MCP skills is therefore higher than equivalent failures in passive web applications.
SA-SKL-CSP-005: Missing COEP/COOP headers allowing SharedArrayBuffer timing attacks
Spectre (CVE-2018-3639 and related variants) demonstrated that high-resolution timers combined with speculative execution allow JavaScript in one tab to read memory from another process. Browsers responded by disabling SharedArrayBuffer — which provides a shared memory region that can be used as a precise timer — except in cross-origin isolated contexts. A context is cross-origin isolated when the page is served with both Cross-Origin-Embedder-Policy: require-corp (which ensures every sub-resource explicitly opts in to being embedded) and Cross-Origin-Opener-Policy: same-origin (which places the page in its own browsing context group, preventing access from opener pages on other origins). Without these headers, SharedArrayBuffer is unavailable in modern browsers — but a skill that serves pages or workers without them cannot use the high-performance features that depend on cross-origin isolation, and it signals to the browser that it is willing to share a browsing context group with untrusted openers. An attacker who can open the skill’s page as a popup from their origin can use timing side-channels to probe the skill page’s memory state. The fix is simple: set both headers on every HTTP response the skill sends.
// VULNERABLE: Express server without COEP/COOP headers
// SharedArrayBuffer is unavailable, and the page shares a browsing context
// group with any opener — timing side-channels are possible via performance.now()
import express from 'express';
const app = express();
// No COEP/COOP headers set — page is not cross-origin isolated
app.get('/skill/preview', (req, res) => {
// Opener pages on attacker.com can window.open() this URL,
// retain a reference to the window object, and use timing side-channels
res.send(renderSkillPreview(req.query.skillId as string));
});
app.get('/skill/worker.js', (req, res) => {
res.setHeader('Content-Type', 'application/javascript');
// Worker script cannot use SharedArrayBuffer — but more importantly,
// the absence of COEP means cross-origin resources load without opt-in
res.send(generateWorkerScript());
});
// SECURE: set COEP and COOP on every response via middleware
import express from 'express';
const app = express();
// Cross-origin isolation middleware — apply to all routes before any handler
app.use((req, res, next) => {
// Require all sub-resources to opt in to being embedded (CORP/CORS headers)
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// Place page in its own browsing context group — no opener access from other origins
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
// Also set CORP on same-origin responses consumed as sub-resources
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
next();
});
app.get('/skill/preview', (req, res) => {
// Page is now cross-origin isolated:
// - SharedArrayBuffer is available for legitimate use
// - Opener pages on other origins cannot retain a cross-origin window reference
// - Timing side-channels are mitigated by browser-enforced isolation
res.send(renderSkillPreview(req.query.skillId as string));
});
app.get('/skill/worker.js', (req, res) => {
res.setHeader('Content-Type', 'application/javascript');
// Worker inherits COEP from the document — sub-resources must opt in
res.send(generateWorkerScript());
});
// DETECTION (SkillAudit):
// Express/Fastify app with GET/POST routes serving HTML or JS responses
// with no res.setHeader('Cross-Origin-Embedder-Policy', ...) anywhere in middleware chain
const missingCOEP = searchAST(
'res.send | res.render | res.sendFile (Content-Type: text/html OR application/javascript) ' +
'AND no res.setHeader("Cross-Origin-Embedder-Policy") in middleware chain'
);
if (missingCOEP.length > 0) → HIGH // SA-SKL-CSP-005
COEP and COOP must be set together: setting only one header does not achieve cross-origin isolation. COOP without COEP prevents opener access but does not restrict sub-resource loading, so cross-origin resources can still be embedded without opt-in. COEP without COOP restricts sub-resources but leaves the page vulnerable to opener-based timing attacks. Both headers must be present on the same response for the browser to grant cross-origin isolation status. Additionally, COEP: require-corp will break any cross-origin sub-resource that does not send a Cross-Origin-Resource-Policy or Access-Control-Allow-Origin header — audit your CDN and third-party resource dependencies before enabling.
Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin; page is not cross-origin isolated, enabling Spectre-class timing side-channels via SharedArrayBuffer and opener window access from other origins. Detection: HTML/JS responses without COEP middleware → HIGH.SA-SKL-CSP-006: CORS misconfiguration allows private API access from external origins
CORS (Cross-Origin Resource Sharing) controls which origins browsers allow to read HTTP responses. When a skill exposes an HTTP API consumed by browser clients, the CORS configuration determines which external pages can trigger credentialed requests and read the responses. Two common misconfigurations combine to create a private data exfiltration path. First, using Access-Control-Allow-Origin: * (wildcard) — while convenient for public APIs, the wildcard origin is explicitly prohibited from being combined with Access-Control-Allow-Credentials: true by the CORS specification; browsers reject this combination, but it signals a lack of CORS understanding that often accompanies other misconfigurations. Second, setting a specific but untrusted origin — Access-Control-Allow-Origin: https://untrusted-origin.com — on endpoints that return private user data allows that origin to read the response of credentialed requests. In both cases, an attacker-controlled page on the allowed origin can use the victim’s session cookies to make authenticated requests to the skill’s API and read the response body, exfiltrating private data without any interaction beyond the victim visiting the attacker’s page. The fix is an explicit allowlist of trusted origins, validated against a list at request time, with credentials only allowed for explicitly trusted origins.
// VULNERABLE: wildcard CORS with credentials — browser rejects this per spec,
// but it reflects a misconfiguration that often appears alongside
// per-route misconfigurations that do allow specific untrusted origins
import express from 'express';
import cors from 'cors';
const app = express();
// This combination is invalid per CORS spec:
// wildcard + credentials → browser blocks the response, but skill is broken
// (common developer mistake that signals CORS is not well understood)
app.use(cors({ origin: '*', credentials: true }));
// ALSO VULNERABLE: specific untrusted origin set on a private API endpoint
app.get('/api/user/profile', cors({ origin: 'https://untrusted-origin.com', credentials: true }), async (req, res) => {
// attacker.com opens a fetch() to this endpoint with cookies:
// fetch('https://skill.skillaudit.dev/api/user/profile', { credentials: 'include' })
// Browser sends session cookie → response is private user data
// ACAO: https://untrusted-origin.com → browser allows attacker.com to READ the response
const user = await getUserFromSession(req.cookies.session);
res.json(user); // ← private profile data sent to attacker-controlled origin
});
// VULNERABLE: dynamic origin reflection without validation
app.use((req, res, next) => {
// Reflects caller's Origin header back — any origin becomes an allowed origin
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin); // ← reflects any origin
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
next();
});
// SECURE: explicit allowlist, credentials only for trusted origins
const ALLOWED_ORIGINS = [
'https://skillaudit.dev',
'https://app.skillaudit.dev',
];
function corsMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.includes(origin)) {
// Origin is in the allowlist — permit with credentials
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Vary: Origin tells caches not to reuse this response for other origins
res.setHeader('Vary', 'Origin');
}
// If origin is not in allowlist: no ACAO header → browser blocks the response
// Do NOT fall through to a wildcard — absence of ACAO is the secure default
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
return res.status(204).end();
}
next();
}
app.use(corsMiddleware);
app.get('/api/user/profile', async (req, res) => {
const user = await getUserFromSession(req.cookies.session);
res.json(user); // safe: only ALLOWED_ORIGINS can read this with credentials
});
// DETECTION (SkillAudit):
// cors({ origin: '*', credentials: true })
// OR cors({ origin: /.*/ }) (regex matching all origins)
// OR res.setHeader('Access-Control-Allow-Origin', req.headers.origin) without allowlist check
// AND endpoint accesses cookies, sessions, or private user data
const corsMisconfig = searchAST(
"cors({ origin: '*', credentials: true }) " +
"OR ACAO = req.headers.origin without ALLOWED_ORIGINS.includes(origin) guard " +
"AND (req.cookies | session | getUserFromSession)"
);
if (corsMisconfig.length > 0) → HIGH // SA-SKL-CSP-006
Reflected origin CORS is the most dangerous pattern: while Access-Control-Allow-Origin: * is incompatible with credentials and is blocked by browsers in that combination, reflected origin — res.setHeader('Access-Control-Allow-Origin', req.headers.origin) — is functionally equivalent to a wildcard but works with Access-Control-Allow-Credentials: true. Any origin that sends a request gets its own origin reflected back, which the browser accepts as a valid ACAO header and allows credentials. Always validate the origin against an explicit allowlist before setting ACAO. If no Origin header is present (server-to-server requests), set no ACAO header at all.
Access-Control-Allow-Origin: * with credentials, untrusted specific origin on private endpoints, or reflected origin without allowlist validation; attacker-controlled page reads private API responses via credentialed cross-origin requests using victim’s session cookies. Detection: wildcard CORS with credentials or reflected origin on authenticated endpoints → HIGH.SA-SKL-CSP-007: postMessage without origin check allows cross-frame message injection
The window.postMessage API is the standard mechanism for communication between cross-origin frames and windows. When a skill embeds content in an iframe — to sandbox tool output, display a preview, or communicate with a parent application — both sides use postMessage and message event listeners to exchange data. The critical requirement is that every message event handler must verify the origin of the sending window before acting on the message data. An event handler that calls window.addEventListener('message', (e) => { processCommand(e.data) }) without first checking e.origin will execute commands sent by any page in the browser, including attacker-controlled pages. Because any web page can open the skill in a popup or embed it in an iframe (if framing is permitted), the postMessage channel is accessible to any attacker who can get the victim to visit their page. The attack is particularly dangerous in MCP skills because the commands processed by the skill may include tool invocations, file operations, or API calls — the injected message can direct the skill to perform privileged actions on behalf of the attacker.
// VULNERABLE: message handler processes commands without checking origin
// Any page that can embed the skill or open it as a popup can send commands
// In the skill iframe or popup page:
window.addEventListener('message', (e) => {
// e.origin is ignored — message from ANY origin is processed
processCommand(e.data); // ← attacker controls e.data
});
// More subtle variant: destructuring without origin check
window.addEventListener('message', ({ data }) => {
// Origin still not checked — { data } destructuring discards e.origin
if (data.type === 'execute') {
executeSkillAction(data.payload); // ← arbitrary execution from any origin
}
if (data.type === 'navigate') {
window.location.href = data.url; // ← open redirect from any origin
}
});
// ATTACK: attacker's page opens skill in popup and sends a malicious command
// (attacker.com/exploit.html)
const skillWindow = window.open('https://skill.skillaudit.dev/tool/preview');
// Wait for skill to load, then inject a command
setTimeout(() => {
skillWindow.postMessage(
{ type: 'execute', payload: { tool: 'delete_user_data', userId: 'victim' } },
'*' // ← '*' as targetOrigin sends to any origin
);
}, 2000);
// SECURE: always check e.origin before processing message data
const TRUSTED_ORIGIN = 'https://skillaudit.dev';
window.addEventListener('message', (e) => {
// Reject messages from any origin other than the trusted parent
if (e.origin !== TRUSTED_ORIGIN) {
console.warn(`Rejected postMessage from untrusted origin: ${e.origin}`);
return; // ← silently discard — do not send an error response
}
// Now safe to process: message is from the trusted skill host
processCommand(e.data);
});
// SECURE: parent page also specifies targetOrigin when posting to child frame
// Do NOT use '*' as targetOrigin — specify the exact expected frame origin
const childFrame = document.getElementById('skill-frame') as HTMLIFrameElement;
function sendToSkillFrame(command: object) {
childFrame.contentWindow?.postMessage(
command,
'https://skill.skillaudit.dev' // ← targetOrigin: only the correct frame receives it
// '*' would send to any origin, including an attacker who navigated the frame
);
}
// DETECTION (SkillAudit):
// window.addEventListener('message', handler) where handler body
// does NOT contain `if (e.origin !== ` or `e.origin ===` check
// before first use of e.data
const postMessageNoOriginCheck = searchAST(
"addEventListener('message', (e) => { ... })" +
"AND handler body uses e.data | e.data.* before any e.origin comparison"
);
if (postMessageNoOriginCheck.length > 0) → CRITICAL // SA-SKL-CSP-007
The origin check must come before any use of e.data: a common mistake is to check the origin conditionally inside a branch that only runs for certain message types — for example, checking origin only for type: 'execute' but not for type: 'config'. Any branch that uses e.data before an origin check is exploitable. Place the origin check as the first statement in the handler and return immediately on failure. Never log or echo back e.data before checking origin — even reflecting the message in an error response can assist an attacker probing the handler for injectable message types. Also note that e.source is available and gives you the sender window object, but the origin string (e.origin) is the correct field to check, not the window reference.
window.addEventListener('message', handler) processes e.data without verifying e.origin; any page that embeds the skill or opens it as a popup can inject arbitrary commands including tool invocations and navigation redirects. Fix: if (e.origin !== 'https://skillaudit.dev') return; as the first handler statement. Detection: message handler uses e.data before e.origin check → CRITICAL.SA-SKL-CSP-008: Fetch metadata headers not checked — cross-site requests reach skill endpoints
Since 2019, modern browsers have sent a set of Sec-Fetch-* fetch metadata headers with every request. These headers describe the context of the request from the browser’s perspective: Sec-Fetch-Site reports whether the request is same-origin, same-site, cross-site, or has no initiator; Sec-Fetch-Mode reports the request mode (navigate, cors, no-cors, same-origin, websocket); and Sec-Fetch-Dest reports the destination (document, script, image, fetch, etc.). Together, they allow a server to implement a Resource Isolation Policy: requests that should only come from the same origin (API calls, form submissions) can be rejected at the request-handling layer before any authentication logic runs, simply by checking that Sec-Fetch-Site is same-origin, same-site, or none (direct navigation). An MCP skill that ignores these headers accepts all cross-site requests, including cross-site navigation abuse, cross-origin no-cors fetches that bypass CORS, and cross-site form submissions that exploit missing CSRF protection. The Resource Isolation Policy is a defence-in-depth layer that stops cross-origin attacks before they reach business logic — it does not replace CSRF tokens, but it eliminates a large class of cross-origin abuse that pre-dates CSRF token support.
// VULNERABLE: no Sec-Fetch-* header check — all cross-site requests are accepted
import express from 'express';
const app = express();
// No fetch metadata middleware — cross-site requests reach all endpoints
app.post('/api/skill/execute', async (req, res) => {
// A cross-site form submission or no-cors fetch can reach this endpoint
// if CSRF protection is absent or bypassable
// Sec-Fetch-Site: cross-site → should be rejected before this handler runs
const result = await executeSkillTool(req.body);
res.json(result);
});
// SECURE: Resource Isolation Policy middleware using Sec-Fetch-* headers
function resourceIsolationPolicy(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const fetchSite = req.headers['sec-fetch-site'] as string | undefined;
const fetchMode = req.headers['sec-fetch-mode'] as string | undefined;
const fetchDest = req.headers['sec-fetch-dest'] as string | undefined;
// If the browser sent no Sec-Fetch-Site header, the request came from
// a non-browser client (curl, server-to-server) or a very old browser.
// Allow it to pass — this policy targets browser-initiated cross-origin attacks.
// Server-to-server requests should be protected by API key / mutual TLS.
if (!fetchSite) {
return next();
}
// Allow same-origin and same-site requests unconditionally
if (fetchSite === 'same-origin' || fetchSite === 'same-site') {
return next();
}
// Allow direct navigations (no initiator: user typed URL, bookmark, etc.)
if (fetchSite === 'none') {
return next();
}
// At this point: fetchSite === 'cross-site'
// Allow only simple top-level navigations (GET/HEAD, dest: document)
// which are acceptable for the landing page. Reject everything else.
if (
fetchSite === 'cross-site' &&
(req.method === 'GET' || req.method === 'HEAD') &&
fetchDest === 'document'
) {
return next();
}
// Reject cross-site requests to API endpoints and form handlers
// This stops: cross-site form submissions, no-cors fetch attacks,
// cross-origin script/image loads being used as oracles
res.status(403).json({
error: 'Forbidden',
reason: 'Cross-site request rejected by resource isolation policy',
});
}
// Apply before all route handlers — blocked before business logic runs
app.use(resourceIsolationPolicy);
app.post('/api/skill/execute', async (req, res) => {
// Only reaches here if Sec-Fetch-Site is same-origin, same-site, or none
// Cross-site POST requests are rejected at the middleware layer above
const result = await executeSkillTool(req.body);
res.json(result);
});
// ALSO: for public API endpoints that legitimately accept cross-origin fetch,
// exempt them from the isolation policy but ensure CORS is correctly configured
app.get('/api/public/status', (req, res) => {
// Public endpoint — cross-site fetch allowed, but no credentials
res.setHeader('Access-Control-Allow-Origin', '*');
res.json({ status: 'ok' });
});
// DETECTION (SkillAudit):
// Express/Fastify app with POST routes or state-mutating endpoints
// with no req.headers['sec-fetch-site'] check anywhere in the middleware chain
const missingFetchMetadata = searchAST(
"app.post | app.put | app.delete | app.patch routes " +
"AND no req.headers['sec-fetch-site'] | req.headers['sec-fetch-mode'] check " +
"in any middleware applied to those routes"
);
if (missingFetchMetadata.length > 0) → HIGH // SA-SKL-CSP-008
Sec-Fetch headers are browser-enforced and cannot be spoofed by cross-origin scripts: the Sec- prefix is a forbidden header name in the browser’s Fetch API — JavaScript cannot set headers beginning with Sec- on fetch or XMLHttpRequest calls. This means the values the server receives in these headers genuinely reflect the browser’s understanding of the request context, not values the JavaScript initiator chose to set. However, non-browser clients (curl, Postman, server-to-server HTTP) will omit these headers entirely — your Resource Isolation Policy should treat absent headers as permitted (pass-through) and protect server-to-server paths with API authentication instead.
Sec-Fetch-Site: cross-site requests; cross-site form submissions, no-cors fetches, and cross-origin navigation abuse reach business logic before any CSRF protection runs. Detection: state-mutating routes with no sec-fetch-site middleware check → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| CSP-005: Missing COEP/COOP headers | HIGH | Add middleware that sets Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin on all responses; also set Cross-Origin-Resource-Policy: same-origin for same-origin sub-resources |
| CSP-006: CORS wildcard / untrusted origin | HIGH | Replace wildcard or reflected ACAO with an explicit ALLOWED_ORIGINS array; only set Access-Control-Allow-Credentials: true for origins in the allowlist; set Vary: Origin on all CORS responses |
| CSP-007: postMessage without origin check | CRITICAL | Add if (e.origin !== 'https://skillaudit.dev') return; as the first statement in every message event handler; specify the exact target origin (not '*') in all postMessage() calls |
| CSP-008: Fetch metadata headers not checked | HIGH | Implement a Resource Isolation Policy middleware that rejects Sec-Fetch-Site: cross-site requests to state-mutating endpoints; allow same-origin, same-site, none, and cross-site GET navigations; exempt intended public API endpoints |
SkillAudit detects all four SA-SKL-CSP-005 through CSP-008 cross-origin isolation failure patterns through static analysis of Express/Fastify middleware chains, message event handler ASTs, and CORS configuration objects. These findings complement the CSP injection and bypass patterns covered in SA-SKL-CSP-001 through CSP-004 (Content Security Policy bypass), which addresses inline script injection, nonce reuse, and unsafe CSP directives. For the full cross-origin and content-security picture see the complete 15-family SA-SKL attack surface reference.
Scan your Claude skill for cross-origin isolation vulnerabilities
SkillAudit detects missing COEP/COOP headers, CORS misconfigurations, unguarded postMessage handlers, and absent fetch metadata checks across the full SA-SKL-CSP family in 60 seconds.
Get your free audit