CVE-2026-16407
Overview
Background
- Service Worker
- a script the browser runs in the background to intercept and control network requests for a page’s scope.
- `importScripts()`
- a classic (non-module) worker API that synchronously fetches and executes additional scripts into the worker’s global scope.
- JavaScript MIME type
- a content type such as
text/javascriptthatnsContentUtils::IsJavascriptMIMETyperecognizes as executable script. - JSON module import
- an ES module import of the form
import data from "./x.json" with { type: "json" }, the only context where a JSON MIME type is legitimately loadable.
Root Cause Analysis
The MIME-type validation in CompareNetwork::OnStreamComplete (service worker script comparison) and in NetworkLoadHandler::PrepareForRequest (worker script loading) accepted a response as a valid script whenever nsContentUtils::IsJsonMimeType(mimeTypeUTF16) returned true, regardless of whether the load was a top-level script, a classic importScripts() call, or an actual JSON module import. This violated the invariant that only genuine JSON module imports may carry a JSON MIME type; a top-level service worker script or a classic importScripts() resource served as application/json, text/json, or application/manifest+json but containing valid JavaScript would be executed.
The fix in CompareNetwork::OnStreamComplete gates the JSON allowance on !mIsMainScript, rejecting a top-level script served as JSON.
The fix in NetworkLoadHandler::PrepareForRequest narrows the JSON allowance to loads that are simultaneously non-top-level, a module request (loadContext->mRequest->IsModuleRequest()), and of module type JS::ModuleType::JSON, so classic importScripts() and the top-level worker script can no longer smuggle in JSON-typed script.
The fix works because it re-couples the JSON MIME allowance to the exact load context (JS::ModuleType::JSON) that the JSON exception was ever meant to cover.
!mIsMainScript, and IsModuleRequest() plus mModuleType == JS::ModuleType::JSON) that confine JSON to its legitimate use.Attack Path
- Host a JSON-typed script
An attacker or a site that only controls a JSON-serving endpoint provides a resource whose body is valid JavaScript but whose
Content-Typeisapplication/json,text/json, orapplication/manifest+json. - Register or import it
The resource is used as a top-level service worker script, or is pulled in via classic
importScripts(), where a JavaScript MIME type would normally be required. - Bypass the MIME check
The old
IsJsonMimeTypeallowance passes the resource throughCompareNetwork::OnStreamComplete/NetworkLoadHandler::PrepareForRequestwithout the JavaScript-MIME restriction. - Execute the script The JSON-typed body is run as worker/service-worker code, defeating the MIME-type mitigation intended to prevent scripts from being sourced from non-script content types.
Impact Assessment
importScripts(); the issue is rated low severity as a mitigation bypass rather than a direct memory-safety or cross-origin compromise.Changed Functions
| Function | Change | Notes |
|---|---|---|
fortesting/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js |
modified | |
promise_testtesting/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js |
modified |
Files Changed
dom/serviceworkers/ServiceWorkerScriptCache.cppdom/workers/loader/NetworkLoadHandler.cpptesting/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.jstesting/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
Audit Directions
- MIME allowlist without contextFlag any script-load path that accepts a broad content-type predicate like
IsJsonMimeTypewithout also checking whether the load is top-level, classic, or a specific module type. - Module-type couplingVerify that JSON (and wasm) MIME exceptions are always gated on the concrete
JS::ModuleTypeandIsModuleRequest()state rather than being applied to all worker loads. - Dual enforcement pointsConfirm both the registration/comparison path (
ServiceWorkerScriptCache.cpp) and the network load path (NetworkLoadHandler.cpp) apply the same MIME restrictions, since a check missing in either enables the bypass.
Patch
diff --git a/dom/serviceworkers/ServiceWorkerScriptCache.cpp b/dom/serviceworkers/ServiceWorkerScriptCache.cpp
index 6a1211f96de..2e80b226620 100644
--- a/dom/serviceworkers/ServiceWorkerScriptCache.cpp
+++ b/dom/serviceworkers/ServiceWorkerScriptCache.cpp
@@ -1087,9 +1087,12 @@ CompareNetwork::OnStreamComplete(nsIStreamLoader* aLoader,
}
auto mimeTypeUTF16 = NS_ConvertUTF8toUTF16(mimeType);
+ // The top-level service worker script must be served with a JavaScript
+ // MIME type. JSON is only permitted for non-top-level (imported) modules,
+ // such as `import data from "./x.json" with { type: "json" }`.
if (mimeTypeUTF16.IsEmpty() ||
!(nsContentUtils::IsJavascriptMIMEType(mimeTypeUTF16) ||
- nsContentUtils::IsJsonMimeType(mimeTypeUTF16))) {
+ (!mIsMainScript && nsContentUtils::IsJsonMimeType(mimeTypeUTF16)))) {
ServiceWorkerManager::LocalizeAndReportToAllClients(
mRegistration->Scope(), "ServiceWorkerRegisterMimeTypeError2",
nsTArray<nsString>{NS_ConvertUTF8toUTF16(mRegistration->Scope()),
diff --git a/dom/workers/loader/NetworkLoadHandler.cpp b/dom/workers/loader/NetworkLoadHandler.cpp
index 75a46add9dd..7d384eb01d9 100644
--- a/dom/workers/loader/NetworkLoadHandler.cpp
+++ b/dom/workers/loader/NetworkLoadHandler.cpp
@@ -353,8 +353,12 @@ nsresult NetworkLoadHandler::PrepareForRequest(nsIRequest* aRequest) {
auto mimeTypeUTF16 = NS_ConvertUTF8toUTF16(mimeType);
if (!nsContentUtils::IsJavascriptMIMEType(mimeTypeUTF16)) {
- // JSON is allowed as a non-toplevel.
+ // JSON is only allowed for non-toplevel JSON module imports, not for
+ // classic importScripts() or the top-level worker script.
if (!((!loadContext->IsTopLevel() &&
+ loadContext->mRequest->IsModuleRequest() &&
+ loadContext->mRequest->AsModuleRequest()->mModuleType ==
+ JS::ModuleType::JSON &&
nsContentUtils::IsJsonMimeType(mimeTypeUTF16))
#ifdef NIGHTLY_BUILD
// Allow wasm modules.
diff --git a/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js b/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
index d4f1f3e26d8..7658eeace69 100644
--- a/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
+++ b/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
@@ -1,6 +1,11 @@
const badMimeTypes = [
null, // no MIME type
'text/plain',
+ // JSON is only valid for JSON module imports, never for classic
+ // importScripts(), even when the body is valid JavaScript.
+ 'application/json',
+ 'text/json',
+ 'application/manifest+json',
];
const validMimeTypes = [
diff --git a/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js b/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
index 037e6c0fde2..7a64cd91e41 100644
--- a/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
+++ b/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
@@ -21,6 +21,32 @@ function registration_tests_mime_types(register_method) {
'Registration of plain text script should fail.');
}, 'Registering script with bad MIME type');
+ // The top-level service worker script must have a JavaScript MIME type. A
+ // JSON MIME type is only valid for imported (non-top-level) modules, so
+ // registering a top-level script served as JSON must fail even when the body
+ // happens to be valid JavaScript.
+ const jsonMimeTypes = [
+ 'application/json',
+ 'text/json',
+ 'application/manifest+json',
+ ];
+
+ for (const jsonMimeType of jsonMimeTypes) {
+ promise_test(function(t) {
+ // Encode the MIME type so characters such as '+' survive the query
+ // string instead of being decoded to a space by the server.
+ var script =
+ `resources/mime-type-worker.py?mime=${encodeURIComponent(jsonMimeType)}`;
+ // Use a scope unique to each MIME type so the registrations don't
+ // interfere with one another.
+ var scope = `resources/scope/json-mime-type-worker/${jsonMimeType}`;
+ return promise_rejects_dom(t,
+ 'SecurityError',
+ register_method(script, {scope: scope}),
+ 'Registration of JSON MIME type script should fail.');
+ }, `Registering script with JSON MIME type ${jsonMimeType}`);
+ }
+
/**
* ServiceWorkerContainer.register() should throw a TypeError, according to
* step 17.1 of https://w3c.github.io/ServiceWorker/#importscripts
Regression Test / PoC
diff --git a/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js b/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
index d4f1f3e26d8..7658eeace69 100644
--- a/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
+++ b/testing/web-platform/tests/service-workers/service-worker/resources/import-scripts-mime-types-worker.js
@@ -1,6 +1,11 @@
const badMimeTypes = [
null, // no MIME type
'text/plain',
+ // JSON is only valid for JSON module imports, never for classic
+ // importScripts(), even when the body is valid JavaScript.
+ 'application/json',
+ 'text/json',
+ 'application/manifest+json',
];
const validMimeTypes = [
diff --git a/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js b/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
index 037e6c0fde2..7a64cd91e41 100644
--- a/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
+++ b/testing/web-platform/tests/service-workers/service-worker/resources/registration-tests-mime-types.js
@@ -21,6 +21,32 @@ function registration_tests_mime_types(register_method) {
'Registration of plain text script should fail.');
}, 'Registering script with bad MIME type');
+ // The top-level service worker script must have a JavaScript MIME type. A
+ // JSON MIME type is only valid for imported (non-top-level) modules, so
+ // registering a top-level script served as JSON must fail even when the body
+ // happens to be valid JavaScript.
+ const jsonMimeTypes = [
+ 'application/json',
+ 'text/json',
+ 'application/manifest+json',
+ ];
+
+ for (const jsonMimeType of jsonMimeTypes) {
+ promise_test(function(t) {
+ // Encode the MIME type so characters such as '+' survive the query
+ // string instead of being decoded to a space by the server.
+ var script =
+ `resources/mime-type-worker.py?mime=${encodeURIComponent(jsonMimeType)}`;
+ // Use a scope unique to each MIME type so the registrations don't
+ // interfere with one another.
+ var scope = `resources/scope/json-mime-type-worker/${jsonMimeType}`;
+ return promise_rejects_dom(t,
+ 'SecurityError',
+ register_method(script, {scope: scope}),
+ 'Registration of JSON MIME type script should fail.');
+ }, `Registering script with JSON MIME type ${jsonMimeType}`);
+ }
+
/**
* ServiceWorkerContainer.register() should throw a TypeError, according to
* step 17.1 of https://w3c.github.io/ServiceWorker/#importscripts