Repository: electron/electron
Pull request: fix: propagate requesting frame through sync permission checks

## Description of Change

`WebContentsPermissionHelper::CheckPermission` hardcoded `GetPrimaryMainFrame()` and used `web_contents_->GetLastCommittedURL()`, so the two synchronous checks routed through it — Web Serial and camera/microphone — always delivered the main frame's origin to `setPermissionCheckHandler`, along with wrong `details.isMainFrame` / `details.requestingUrl`, even when a cross-origin subframe triggered the check.

Thread the requesting `RenderFrameHost` through `CheckPermission` and its two callers so the permission manager receives the real frame. Other paths (HID/USB/FSA, `Permissions.query()`) already pass the iframe frame directly and are untouched.

## Checklist

- [x] PR description included and stakeholders cc'd
- [x] \`npm test\` passes
- [x] Relevant documentation changed
- [x] PR title follows semantic [commit guidelines](https://github.com/electron/electron/blob/main/docs/development/pull-requests.md#commit-message-guidelines)
- [x] PR release notes describe the change in a way relevant to app developers

## Release Notes

Notes: no-notes

Changed files:
filename: shell/browser/api/electron_api_web_contents.cc
patch: @@ -1767,7 +1767,8 @@ bool WebContents::CheckMediaAccessPermission(
       content::WebContents::FromRenderFrameHost(render_frame_host);
   auto* permission_helper =
       WebContentsPermissionHelper::FromWebContents(web_contents);
-  return permission_helper->CheckMediaAccessPermission(security_origin, type);
+  return permission_helper->CheckMediaAccessPermission(render_frame_host,
+                                                       security_origin, type);
 }
 
 void WebContents::RequestMediaAccessPermission(

filename: shell/browser/serial/electron_serial_delegate.cc
patch: @@ -51,8 +51,7 @@ bool ElectronSerialDelegate::CanRequestPortPermission(
   auto* web_contents = content::WebContents::FromRenderFrameHost(frame);
   auto* permission_helper =
       WebContentsPermissionHelper::FromWebContents(web_contents);
-  return permission_helper->CheckSerialAccessPermission(
-      frame->GetLastCommittedOrigin());
+  return permission_helper->CheckSerialAccessPermission(frame);
 }
 
 bool ElectronSerialDelegate::HasPortPermission(

filename: shell/browser/web_contents_permission_helper.cc
patch: @@ -228,14 +228,14 @@ void WebContentsPermissionHelper::RequestPermission(
 }
 
 bool WebContentsPermissionHelper::CheckPermission(
+    content::RenderFrameHost* requesting_frame,
     blink::PermissionType permission,
     base::DictValue details) const {
-  auto* rfh = web_contents_->GetPrimaryMainFrame();
   auto* permission_manager = static_cast<ElectronPermissionManager*>(
       web_contents_->GetBrowserContext()->GetPermissionControllerDelegate());
-  auto origin = web_contents_->GetLastCommittedURL();
-  return permission_manager->CheckPermissionWithDetails(permission, rfh, origin,
-                                                        std::move(details));
+  auto origin = requesting_frame->GetLastCommittedOrigin().GetURL();
+  return permission_manager->CheckPermissionWithDetails(
+      permission, requesting_frame, origin, std::move(details));
 }
 
 void WebContentsPermissionHelper::RequestFullscreenPermission(
@@ -313,6 +313,7 @@ void WebContentsPermissionHelper::RequestOpenExternalPermission(
 }
 
 bool WebContentsPermissionHelper::CheckMediaAccessPermission(
+    content::RenderFrameHost* requesting_frame,
     const url::Origin& security_origin,
     blink::mojom::MediaStreamType type) const {
   base::DictValue details;
@@ -321,14 +322,16 @@ bool WebContentsPermissionHelper::CheckMediaAccessPermission(
   auto blink_type = type == blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE
                         ? blink::PermissionType::AUDIO_CAPTURE
                         : blink::PermissionType::VIDEO_CAPTURE;
-  return CheckPermission(blink_type, std::move(details));
+  return CheckPermission(requesting_frame, blink_type, std::move(details));
 }
 
 bool WebContentsPermissionHelper::CheckSerialAccessPermission(
-    const url::Origin& embedding_origin) const {
+    content::RenderFrameHost* requesting_frame) const {
   base::DictValue details;
-  details.Set("securityOrigin", embedding_origin.GetURL().spec());
-  return CheckPermission(blink::PermissionType::SERIAL, std::move(details));
+  details.Set("securityOrigin",
+              requesting_frame->GetLastCommittedOrigin().GetURL().spec());
+  return CheckPermission(requesting_frame, blink::PermissionType::SERIAL,
+                         std::move(details));
 }
 
 WEB_CONTENTS_USER_DATA_KEY_IMPL(WebContentsPermissionHelper);

filename: shell/browser/web_contents_permission_helper.h
patch: @@ -47,9 +47,11 @@ class WebContentsPermissionHelper
                                      const GURL& url);
 
   // Synchronous Checks
-  bool CheckMediaAccessPermission(const url::Origin& security_origin,
+  bool CheckMediaAccessPermission(content::RenderFrameHost* requesting_frame,
+                                  const url::Origin& security_origin,
                                   blink::mojom::MediaStreamType type) const;
-  bool CheckSerialAccessPermission(const url::Origin& embedding_origin) const;
+  bool CheckSerialAccessPermission(
+      content::RenderFrameHost* requesting_frame) const;
 
  private:
   explicit WebContentsPermissionHelper(content::WebContents* web_contents);
@@ -61,7 +63,8 @@ class WebContentsPermissionHelper
                          bool user_gesture = false,
                          base::DictValue details = {});
 
-  bool CheckPermission(blink::PermissionType permission,
+  bool CheckPermission(content::RenderFrameHost* requesting_frame,
+                       blink::PermissionType permission,
                        base::DictValue details) const;
 
   // TODO(clavin): refactor to use the WebContents provided by the

filename: spec/api-session-spec.ts
patch: @@ -1941,6 +1941,60 @@ describe('session module', () => {
       expect(handlerDetails!.isMainFrame).to.be.false();
       expect(handlerDetails!.embeddingOrigin).to.equal('file:///');
     });
+
+    it('provides iframe origin as requestingOrigin for media check from cross-origin subFrame', async () => {
+      const w = new BrowserWindow({
+        show: false,
+        webPreferences: {
+          partition: 'very-temp-permission-handler-media'
+        }
+      });
+      const ses = w.webContents.session;
+      const iframeUrl = 'https://myfakesite/';
+      let capturedOrigin: string | undefined;
+      let capturedIsMainFrame: boolean | undefined;
+      let capturedRequestingUrl: string | undefined;
+      let capturedSecurityOrigin: string | undefined;
+
+      ses.protocol.interceptStringProtocol('https', (req, cb) => {
+        cb('<html><body>iframe</body></html>');
+      });
+
+      ses.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
+        if (permission === 'media') {
+          capturedOrigin = requestingOrigin;
+          capturedIsMainFrame = details.isMainFrame;
+          capturedRequestingUrl = details.requestingUrl;
+          capturedSecurityOrigin = (details as any).securityOrigin;
+        }
+        return false;
+      });
+
+      try {
+        await w.loadFile(path.join(fixtures, 'api', 'blank.html'));
+        w.webContents.executeJavaScript(`
+          var iframe = document.createElement('iframe');
+          iframe.src = '${iframeUrl}';
+          iframe.allow = 'camera; microphone';
+          document.body.appendChild(iframe);
+          null;
+        `);
+        const [,, frameProcessId, frameRoutingId] = await once(w.webContents, 'did-frame-finish-load');
+        const frame = webFrameMain.fromId(frameProcessId, frameRoutingId)!;
+        await frame.executeJavaScript(
+          'navigator.mediaDevices.enumerateDevices().then(() => {}).catch(() => {});',
+          true
+        );
+
+        expect(capturedOrigin).to.equal(iframeUrl);
+        expect(capturedIsMainFrame).to.be.false();
+        expect(capturedRequestingUrl).to.equal(iframeUrl);
+        expect(capturedSecurityOrigin).to.equal(iframeUrl);
+      } finally {
+        ses.protocol.uninterceptProtocol('https');
+        ses.setPermissionCheckHandler(null);
+      }
+    });
   });
 
   describe('ses.isPersistent()', () => {