Repository: electron/electron
Pull request: test: service worker contextBridge leak

#### Description of Change

closes https://github.com/electron/electron/issues/45188

Adds a test to ensure values passed between service worker contexts do not leak prototypes. This is a reimplementation of a [test from the contextBridge spec file.](https://github.com/electron/electron/blob/eac270bea7798b5f8b62504e1f1161f89f3a189d/spec/api-context-bridge-spec.ts#L879-L989)

I decided to add a new test rather than refactor the contextBridge tests for a few reasons:
1. `ServiceWorkerMain` has no `executeJavaScript` method yet. This prevents us from running arbitrary JS in the main world of service workers.
2. The service worker preload context is more limited than render frame isolated contexts. The `Blob` global doesn't exist which the contextBridge test depends on.
3. The complexity involved in porting `makeBindingWindow` and `callWithBindings` to work with SWs while keeping the code maintainable didn't seem worth it.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [ ] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [ ] relevant documentation, tutorials, templates and examples are changed or added
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: none


Changed files:
filename: spec/api-service-worker-main-spec.ts
patch: @@ -388,6 +388,13 @@ describe('ServiceWorkerMain module', () => {
       const result = await runTest(serviceWorker, { name: 'testEvaluate', args: ['evalConstructorName'] });
       expect(result).to.equal('ServiceWorkerGlobalScope');
     });
+
+    it('does not leak prototypes', async () => {
+      loadWorkerScript();
+      const serviceWorker = await waitForServiceWorker('running');
+      const result = await runTest(serviceWorker, { name: 'testPrototypeLeak', args: [] });
+      expect(result).to.be.true();
+    });
   });
 
   describe('extensions', () => {

filename: spec/fixtures/api/preload-realm/preload-tests.js
patch: @@ -18,6 +18,76 @@ const tests = {
       ? contextBridge.executeInMainWorld({ func, args })
       : contextBridge.executeInMainWorld({ func });
     return result;
+  },
+  testPrototypeLeak: () => {
+    const checkPrototypes = (value) => {
+      // Get prototype in preload world
+      const prototype = Object.getPrototypeOf(value);
+      const constructorName = prototype.constructor.name;
+
+      const result = contextBridge.executeInMainWorld({
+        func: (value) => {
+          // Deeply check that value prototypes exist in the local world
+          const check = (v) => {
+            if (typeof v === 'undefined' || v === null) return true;
+            const prototype = Object.getPrototypeOf(v);
+            const constructorName = prototype.constructor.name;
+            const localPrototype = globalThis[constructorName].prototype;
+            if (prototype !== localPrototype) return false;
+            if (Array.isArray(v)) return v.every(check);
+            if (typeof v === 'object') return Object.values(v).every(check);
+            if (typeof v === 'function') return check(v());
+            return true;
+          };
+          return { protoMatches: check(value), value };
+        },
+        args: [value, constructorName]
+      });
+
+      // Deeply check that value prototypes exist in the local world
+      const check = (v) => {
+        if (typeof v === 'undefined' || v === null) return true;
+        const prototype = Object.getPrototypeOf(v);
+        const constructorName = prototype.constructor.name;
+        const localPrototype = globalThis[constructorName].prototype;
+        if (prototype !== localPrototype) return false;
+        if (Array.isArray(v)) return v.every(check);
+        if (typeof v === 'object') return Object.values(v).every(check);
+        if (typeof v === 'function') return check(v());
+        return true;
+      };
+
+      return (
+        // Prototype matched in main world
+        result.protoMatches &&
+        // Returned value matches prototype
+        check(result.value)
+      );
+    };
+
+    const values = [
+      123,
+      'string',
+      true,
+      [],
+      [123, 'string', true, ['foo']],
+      Symbol('foo'),
+      10n,
+      {},
+      Promise.resolve(),
+      () => {},
+      () => () => null,
+      { [Symbol('foo')]: 123 }
+    ];
+
+    for (const value of values) {
+      if (!checkPrototypes(value)) {
+        const constructorName = Object.getPrototypeOf(value).constructor.name;
+        return `${constructorName} (${value}) leaked in service worker preload`;
+      }
+    }
+
+    return true;
   }
 };
 
@@ -29,6 +99,9 @@ ipcRenderer.on('test', async (_event, uuid, name, ...args) => {
     ipcRenderer.send(`test-result-${uuid}`, { error: false, result });
   } catch (error) {
     console.debug(`erroring test ${name} for ${uuid}`);
-    ipcRenderer.send(`test-result-${uuid}`, { error: true, result: error.message });
+    ipcRenderer.send(`test-result-${uuid}`, {
+      error: true,
+      result: error.message
+    });
   }
 });