javascriptgoogle-chrome-extensionsame-origin-policyframespostmessage

How to identify or to postMessage to a cross-origin <embed> element after redirection


I'm using a Chrome extension using MV3 to convert every frame to a shadow-root (to put inline their content), I need this because I cannot copy and paste iframe/embed/object frames from the browser to OneNote, Word, etc when I select all the page, I have to select the frame content one by one, but this consumes a lot of time. So I decided to convert them, which allows me to copy them all at once with one ctrl-c.

failed solutions:

postMessage

I solved iframes, and the conversion works very well, I used chrome.webNavigation.getAllFrames to inject every frame and its parent, then I postMessage between them the content, and then I replace from the parent the subframe content because I can now identify the subframe by postMessage the frameid I got with getAllFrames. but I could not solve the <embed> frames:

elements (HTMLEmbedElement) do not have contentWindow or contentDocument so we cannot postmessage from the parent to its embed frame. link

window.frames

So to get the content, I used this method: window.frames[] does include windows from <embed>, but it is not possible to tell whether an item in window.frames[] belongs to a specific <embed>, except in the same-origin case, where frames[i].frameElement can be compared with an <embed> element. link

But this method will not always work because ShadowDOM frames aren't exposed in the global window or frames, i mean window.frames do not include iframe/embed if they are inside a shadow roots. 1 2 3 4. And of course there is the cross-origin problem also.

frame.src

Another solution is to compare the frame src I get from getAllFrames (or window.location.href (sent from the embed to its parent)) with the frame.src I get from the frame's parent. This allows me to identify from the parent the correct embed frame, which I have to convert to a shadow-root. But this method fails if the embed frame is redirected to a new url, the standard says it clearly:

element's src attribute does not get updated if the content navigable gets further navigated to other locations. link

getFrameId()

There is also runtime.getFrameId() which seems to solve all this problems, but Chrome do not want to implement it, Firefox implemented it, but I'm using chrome.

Not good solutions:

webrequest

One solution I can use is the webrequest api to intercept the redirections and save the old and new urls to identify the correct frame, but this means that I have to refresh the page, I want to avoid this, because I'm doing all this work to earn time when I copy/paste not to lose more time. Refreshing means I have to wait more time and I need to reopen any <details><summary>... etc

disable web security

I can run chrome with this flags --profile-directory="%Profile_name%" --disable-site-isolation-trials --disable-web-security this disable the web security so it eliminates the same-origin policy restrictions, so cross-origin problems are solved, but i cannot run all the time with this dangerous flags, and running every time a new chrome with this flags just for copy/paste means losing more time...


So now to identify the embed frame from its parent frame, I cannot use frame.src from the parent if it is redirected, and I cannot postmessage from the parent to its child embed frame like I did in iframes, and I cannot use window.frames for cross-origin frames or when the frame is inside a shadow-root. Is there another solution?

If you a need a test case, here is an example of a cross-origin frame which will redirect to a new url

<div><embed src="https://iperasolutions.com" height="500" width="500"></div>

update for wOxxOm suggestion

This did not work for cross-origin frames, it works fine for same-origin frames. there is an open bug about this:

cross origin iframes still can't be debugged via chrome.debugger https://issues.chromium.org/issues/40752731

chrome.debugger.attach({ tabId: tabId }, "1.3", function () {
  // Enable auto-attach to subtargets
  chrome.debugger.sendCommand({ tabId: tabId }, "Target.setAutoAttach", {
    autoAttach: true,
    waitForDebuggerOnStart: false,
    flatten: true,
  }, function () {
    chrome.debugger.sendCommand({ tabId: tabId }, "DOMSnapshot.captureSnapshot", { computedStyles: [] }, function (snapshot) {
      console.log("snapshot", snapshot);
    });
});

update 2 for the second wOxxOm suggestion

DOM.getDocument

DOM.getDocument does not return cross-origin frames, and also this commands Page.getFrameTree and Page.getResourceTree

const getDocument = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", { depth: -1, pierce: true });
console.log("++++++++getDocument",getDocument);

const getFrameTree = await chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree", {});
console.log("++++++++getFrameTree: ", getFrameTree);
  
const getResourceTree = await chrome.debugger.sendCommand({ tabId }, "Page.getResourceTree", {});
console.log("++++++++getResourceTree: ", getResourceTree);

when i run this in the protocol monitor in devtools, cross-origin frames are not returned too

{"command":"DOM.getDocument" ,"parameters":{"depth": -1, "pierce": true}}

if i disable web security by running chrome with --disable-site-isolation-trials --disable-web-security... all the above 3 commands returns cross-origin frames.

here is the full code:

const contextMap = new Map();
try {
  chrome.debugger.onEvent.addListener(handleDebuggerEvents);
  await chrome.debugger.attach({ tabId: tabId }, "1.3");
  // When you call Runtime.enable, it sends executionContextCreated events for all existing execution contexts. So you don’t have to worry about connecting late. https://github.com/ChromeDevTools/devtools-protocol/issues/72
  await chrome.debugger.sendCommand({ tabId: tabId }, "Runtime.enable");
  console.log("contextMap",contextMap);    
  await injectScriptIntoAllFrames(tabId);
  await chrome.debugger.detach({ tabId: tabId });
} catch (error) {
  console.error(error);
}


async function injectScriptIntoAllFrames(tabId) {
  const getDocument = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", { depth: -1, pierce: true });
  if (getDocument) {
    console.log("++++++++getDocument",getDocument);
    await traverseDOM(tabId, getDocument.root);
  }
}

async function traverseDOM(tabId, node) {
  if (node.frameId && (node.nodeName === 'IFRAME' || node.nodeName === 'EMBED' || node.nodeName === 'OBJECT')) {
    console.log("frameid",node.frameId);
    // const contextId = contextMap.get(node.frameId).id;
    // uniqueContextId: is An alternative way to specify the execution context to evaluate in. Compared to contextId that may be reused across processes, this is guaranteed to be system-unique, so it can be used to prevent accidental evaluation of the expression in context different than intended (e.g. as a result of navigation across process boundaries). This is mutually exclusive with `contextId`.EXPERiMENTAL
    const uniqueContextId = contextMap.get(node.frameId).uniqueId;
    console.log("=========uniqueContextId",uniqueContextId);
    await injectScriptIntoFrame(tabId, node.frameId, uniqueContextId);
  }

  if (node.children) {
    for (const child of node.children) {
      await traverseDOM(tabId, child);
    }
  }
}

async function injectScriptIntoFrame(tabId, frameId, uniqueContextId) {
  const evaluateResult = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
    expression: `(async () => { 
      console.log("111",window.badr); 
      await new Promise((resolve) => {setTimeout(resolve, 3000);});
      return window.badr;
    })()`
    ,awaitPromise: true
    // ,contextId: contextId
    ,uniqueContextId: uniqueContextId
    // ,silent: true
  })
  if (evaluateResult) {
    if (evaluateResult.exceptionDetails) {
      console.error(`An error happened or Failed to inject script into frame ${frameId}:`, evaluateResult.exceptionDetails);
    } else {
      console.log("Runtime.evaluate result",evaluateResult.result.value);
    }
  }
}

function handleDebuggerEvents (source, method, params) {
  if (source.tabId !== tabId) return;

  if (method === "Runtime.executionContextsCleared") {
    contextMap.clear();
    console.log("Contexts cleared");
  }

  if (method === "Runtime.executionContextCreated") {
    const context = params.context;
    const frameId = context.auxData && context.auxData.frameId;
  
    // isDefault seems to be true only for main frame context,when i create a createIsolatedWorld or i inject a content script into the frame i see that isDefault is always false for those contexts. for main frame context the "name" seems to be always empty, and "type" is always  type: 'default', type in general may be: 'default'|'isolated'|'worker'
    if (frameId && context.auxData.isDefault) { 
      contextMap.set(frameId, context);
      console.log(`Context saved: ${frameId}`, contextMap.get(frameId));
    }
  } else if (method === "Runtime.executionContextDestroyed") {
    const executionContextId = params.executionContextId;
  
    for (const [frameId, context] of contextMap) {
      if (context.id === executionContextId) {
        contextMap.delete(frameId);
        console.log(`Context removed: ${frameId}`);
        break;
      }
    }
  }
}

DOM.requestChildNodes

this also does not return cross-origin frames.

chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  const tabId = tabs[0].id;
  chrome.debugger.attach({ tabId }, "1.3", () => {
    chrome.debugger.sendCommand({ tabId }, "DOM.getDocument", {}, (result) => {
      const rootNodeId = result.root.nodeId;
      requestChildNodes(tabId, rootNodeId);
    });
  });
});

function requestChildNodes(tabId, nodeId) {
  chrome.debugger.sendCommand({ tabId },"DOM.requestChildNodes",{ nodeId },(result) => {
    console.log("Child nodes requested", result);
    chrome.debugger.sendCommand({ tabId },"DOM.querySelectorAll",{ nodeId: nodeId, selector: '*' },(result) => {
      console.log("All nodes:", result);
    });
  });
}

chrome.debugger.onEvent.addListener((source, method, params) => {
  if (method === "DOM.setChildNodes") {
    console.log("Child nodes set", params);
  }
});

a possible fix for chrome v124+ (not tested!)

i found this which seems similar and applicable to the above commands, it is about Page.getFrameTree

CDP method Page.getFrameTree with chrome.debugger API doesn't return cross-domain iframes

Status: Won't Fix (Intended Behavior)

Page.getFrameTree only returns local frames and it is working as expected. To attach to out-of-process iframes (which are separate CDP targets and on which you can call Page.getFrameTree again to get local frame trees), in the latest Canary you can use CDP's auto-attach to automatically create sessions for all OOPIFs https://chromium-review.googlesource.com/c/chromium/src/+/5398119

source: https://issues.chromium.org/issues/333981074

so it seems it needs at least chrome v124+ and auto-attach, so no chance for win 7 and chrome v109 users like me :(


Solution

  • One way would be to replace all the <embed> elements in the page with <iframe> ones, so that you can communicate with it as you wish.

    Injecting something like below should do.

    [...document.querySelectorAll("embed")].forEach((embed) => {
      const { src, width, height } = embed;
      const frame = Object.assign(document.createElement("iframe"), { src, width, height })
      embed.replaceWith(frame);
    });
    

    (This may cause some layout differences in the page).