summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs
blob: d52cbc570869c36f455a3a02595573d1a0604dd0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
  "devtools.every-frame-target.enabled",
  false
);

const WEBEXTENSION_FALLBACK_DOC_URL =
  "chrome://devtools/content/shared/webextension-fallback.html";

/**
 * Retrieve the addon id corresponding to a given window global.
 * This is usually extracted from the principal, but in case we are dealing
 * with a DevTools webextension fallback window, the addon id will be available
 * in the URL.
 *
 * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal
 *        The WindowGlobal from which we want to extract the addonId. Either a
 *        WindowGlobalParent or a WindowGlobalChild depending on where this
 *        helper is used from.
 * @return {String} Returns the addon id if any could found, null otherwise.
 */
export function getAddonIdForWindowGlobal(windowGlobal) {
  const browsingContext = windowGlobal.browsingContext;
  const isParent = CanonicalBrowsingContext.isInstance(browsingContext);
  // documentPrincipal is only exposed on WindowGlobalParent,
  // use a fallback for WindowGlobalChild.
  const principal = isParent
    ? windowGlobal.documentPrincipal
    : browsingContext.window.document.nodePrincipal;

  // On Android we can get parent process windows where `documentPrincipal` and
  // `documentURI` are both unavailable. Bail out early.
  if (!principal) {
    return null;
  }

  // Most webextension documents are loaded from moz-extension://{addonId} and
  // the principal provides the addon id.
  if (principal.addonId) {
    return principal.addonId;
  }

  // If no addon id was available on the principal, check if the window is the
  // DevTools fallback window and extract the addon id from the URL.
  const href = isParent
    ? windowGlobal.documentURI?.displaySpec
    : browsingContext.window.document.location.href;

  if (href && href.startsWith(WEBEXTENSION_FALLBACK_DOC_URL)) {
    const [, addonId] = href.split("#");
    return addonId;
  }

  return null;
}

/**
 * Helper function to know if a given BrowsingContext should be debugged by scope
 * described by the given session context.
 *
 * @param {BrowsingContext} browsingContext
 *        The browsing context we want to check if it is part of debugged context
 * @param {Object} sessionContext
 *        The Session Context to help know what is debugged.
 *        See devtools/server/actors/watcher/session-context.js
 * @param {Object} options
 *        Optional arguments passed via a dictionary.
 * @param {Boolean} options.forceAcceptTopLevelTarget
 *        If true, we will accept top level browsing context even when server target switching
 *        is disabled. In case of client side target switching, the top browsing context
 *        is debugged via a target actor that is being instantiated manually by the frontend.
 *        And this target actor isn't created, nor managed by the watcher actor.
 * @param {Boolean} options.acceptInitialDocument
 *        By default, we ignore initial about:blank documents/WindowGlobals.
 *        But some code cares about all the WindowGlobals, this flag allows to also accept them.
 *        (Used by _validateWindowGlobal)
 * @param {Boolean} options.acceptSameProcessIframes
 *        If true, we will accept WindowGlobal that runs in the same process as their parent document.
 *        That, even when EFT is disabled.
 *        (Used by _validateWindowGlobal)
 * @param {Boolean} options.acceptNoWindowGlobal
 *        By default, we will reject BrowsingContext that don't have any WindowGlobal,
 *        either retrieved via BrowsingContext.currentWindowGlobal in the parent process,
 *        or via the options.windowGlobal argument.
 *        But in some case, we are processing BrowsingContext very early, before any
 *        WindowGlobal has been created for it. But they are still relevant BrowsingContexts
 *        to debug.
 * @param {WindowGlobal} options.windowGlobal
 *        When we are in the content process, we can't easily retrieve the WindowGlobal
 *        for a given BrowsingContext. So allow to pass it via this argument.
 *        Also, there is some race conditions where browsingContext.currentWindowGlobal
 *        is null, while the callsite may have a reference to the WindowGlobal.
 */
// The goal of this method is to gather all checks done against BrowsingContext and WindowGlobal interfaces
// which leads it to be a lengthy method. So disable the complexity rule which is counter productive here.
// eslint-disable-next-line complexity
export function isBrowsingContextPartOfContext(
  browsingContext,
  sessionContext,
  options = {}
) {
  let {
    forceAcceptTopLevelTarget = false,
    acceptNoWindowGlobal = false,
    windowGlobal,
  } = options;

  // For now, reject debugging chrome BrowsingContext.
  // This is for example top level chrome windows like browser.xhtml or webconsole/index.html (only the browser console)
  //
  // Tab and WebExtension debugging shouldn't target any such privileged document.
  // All their document should be of type "content".
  //
  // This may only be an issue for the Browser Toolbox.
  // For now, we expect the ParentProcessTargetActor to debug these.
  // Note that we should probably revisit that, and have each WindowGlobal be debugged
  // by one dedicated WindowGlobalTargetActor (bug 1685500). This requires some tweaks, at least in console-message
  // resource watcher, which makes the ParentProcessTarget's console message resource watcher watch
  // for all documents messages. It should probably only care about window-less messages and have one target per window global,
  // each target fetching one window global messages.
  //
  // Such project would be about applying "EFT" to the browser toolbox and non-content documents
  if (
    CanonicalBrowsingContext.isInstance(browsingContext) &&
    !browsingContext.isContent
  ) {
    return false;
  }

  if (!windowGlobal) {
    // When we are in the parent process, WindowGlobal can be retrieved from the BrowsingContext,
    // while in the content process, the callsites have to pass it manually as an argument
    if (CanonicalBrowsingContext.isInstance(browsingContext)) {
      windowGlobal = browsingContext.currentWindowGlobal;
    } else if (!windowGlobal && !acceptNoWindowGlobal) {
      throw new Error(
        "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process"
      );
    }
  }
  // If we have a WindowGlobal, there is some additional checks we can do
  if (
    windowGlobal &&
    !_validateWindowGlobal(windowGlobal, sessionContext, options)
  ) {
    return false;
  }
  // Loading or destroying BrowsingContext won't have any associated WindowGlobal.
  // Ignore them by default. They should be either handled via DOMWindowCreated event or JSWindowActor destroy
  if (!windowGlobal && !acceptNoWindowGlobal) {
    return false;
  }

  // Now do the checks specific to each session context type
  if (sessionContext.type == "all") {
    return true;
  }
  if (sessionContext.type == "browser-element") {
    // Check if the document is:
    // - part of the Browser element, or,
    // - a popup originating from the browser element (the popup being loaded in a distinct browser element)
    const isMatchingTheBrowserElement =
      browsingContext.browserId == sessionContext.browserId;
    if (
      !isMatchingTheBrowserElement &&
      !isPopupToDebug(browsingContext, sessionContext)
    ) {
      return false;
    }

    // For client-side target switching, only mention the "remote frames".
    // i.e. the frames which are in a distinct process compared to their parent document
    // If there is no parent, this is most likely the top level document which we want to ignore.
    //
    // `forceAcceptTopLevelTarget` is set:
    // * when navigating to and from pages in the bfcache, we ignore client side target
    // and start emitting top level target from the server.
    // * when the callsite care about all the debugged browsing contexts,
    // no matter if their related targets are created by client or server.
    const isClientSideTargetSwitching =
      !sessionContext.isServerTargetSwitchingEnabled;
    const isTopLevelBrowsingContext = !browsingContext.parent;
    if (
      isClientSideTargetSwitching &&
      !forceAcceptTopLevelTarget &&
      isTopLevelBrowsingContext
    ) {
      return false;
    }
    return true;
  }

  if (sessionContext.type == "webextension") {
    // Next and last check expects a WindowGlobal.
    // As we have no way to really know if this BrowsingContext is related to this add-on,
    // ignore it. Even if callsite accepts browsing context without a window global.
    if (!windowGlobal) {
      return false;
    }

    return getAddonIdForWindowGlobal(windowGlobal) == sessionContext.addonId;
  }
  throw new Error("Unsupported session context type: " + sessionContext.type);
}

/**
 * Return true for popups to debug when debugging a browser-element.
 *
 * @param {BrowsingContext} browsingContext
 *        The browsing context we want to check if it is part of debugged context
 * @param {Object} sessionContext
 *        WatcherActor's session context. This helps know what is the overall debugged scope.
 *        See watcher actor constructor for more info.
 */
function isPopupToDebug(browsingContext, sessionContext) {
  // If enabled, create targets for popups (i.e. window.open() calls).
  // If the opener is the tab we are currently debugging, accept the WindowGlobal and create a target for it.
  //
  // Note that it is important to do this check *after* the isInitialDocument one.
  // Popups end up involving three WindowGlobals:
  // - a first WindowGlobal loading an initial about:blank document (so isInitialDocument is true)
  // - a second WindowGlobal which looks exactly as the first one
  // - a final WindowGlobal which loads the URL passed to window.open() (so isInitialDocument is false)
  //
  // For now, we only instantiate a target for the last WindowGlobal.
  return (
    sessionContext.isPopupDebuggingEnabled &&
    browsingContext.opener &&
    browsingContext.opener.browserId == sessionContext.browserId
  );
}

/**
 * Helper function of isBrowsingContextPartOfContext to execute all checks
 * against WindowGlobal interface which aren't specific to a given SessionContext type
 *
 * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal
 *        The WindowGlobal we want to check if it is part of debugged context
 * @param {Object} sessionContext
 *        The Session Context to help know what is debugged.
 *        See devtools/server/actors/watcher/session-context.js
 * @param {Object} options
 *        Optional arguments passed via a dictionary.
 *        See `isBrowsingContextPartOfContext` jsdoc.
 */
function _validateWindowGlobal(
  windowGlobal,
  sessionContext,
  { acceptInitialDocument, acceptSameProcessIframes }
) {
  // By default, before loading the actual document (even an about:blank document),
  // we do load immediately "the initial about:blank document".
  // This is expected by the spec. Typically when creating a new BrowsingContext/DocShell/iframe,
  // we would have such transient initial document.
  // `Document.isInitialDocument` helps identify this transient document, which
  // we want to ignore as it would instantiate a very short lived target which
  // confuses many tests and triggers race conditions by spamming many targets.
  //
  // We also ignore some other transient empty documents created while using `window.open()`
  // When using this API with cross process loads, we may create up to three documents/WindowGlobals.
  // We get a first initial about:blank document, and a second document created
  // for moving the document in the right principal.
  // The third document will be the actual document we expect to debug.
  // The second document is an implementation artifact which ideally wouldn't exist
  // and isn't expected by the spec.
  // Note that `window.print` and print preview are using `window.open` and are going through this.
  //
  // WindowGlobalParent will have `isInitialDocument` attribute, while we have to go through the Document for WindowGlobalChild.
  const isInitialDocument =
    windowGlobal.isInitialDocument ||
    windowGlobal.browsingContext.window?.document.isInitialDocument;
  if (isInitialDocument && !acceptInitialDocument) {
    return false;
  }

  // We may process an iframe that runs in the same process as its parent and we don't want
  // to create targets for them if same origin targets (=EFT) are not enabled.
  // Instead the WindowGlobalTargetActor will inspect these children document via docShell tree
  // (typically via `docShells` or `windows` getters).
  // This is quite common when Fission is off as any iframe will run in same process
  // as their parent document. But it can also happen with Fission enabled if iframes have
  // children iframes using the same origin.
  const isSameProcessIframe = !windowGlobal.isProcessRoot;
  if (
    isSameProcessIframe &&
    !acceptSameProcessIframes &&
    !isEveryFrameTargetEnabled
  ) {
    return false;
  }

  return true;
}

/**
 * Helper function to know if a given WindowGlobal should be debugged by scope
 * described by the given session context. This method could be called from any process
 * as so accept either WindowGlobalParent or WindowGlobalChild instances.
 *
 * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal
 *        The WindowGlobal we want to check if it is part of debugged context
 * @param {Object} sessionContext
 *        The Session Context to help know what is debugged.
 *        See devtools/server/actors/watcher/session-context.js
 * @param {Object} options
 *        Optional arguments passed via a dictionary.
 *        See `isBrowsingContextPartOfContext` jsdoc.
 */
export function isWindowGlobalPartOfContext(
  windowGlobal,
  sessionContext,
  options
) {
  return isBrowsingContextPartOfContext(
    windowGlobal.browsingContext,
    sessionContext,
    {
      ...options,
      windowGlobal,
    }
  );
}

/**
 * Get all the BrowsingContexts that should be debugged by the given session context.
 * Consider using WatcherActor.getAllBrowsingContexts(options) which will automatically pass the right sessionContext.
 *
 * Really all of them:
 * - For all the privileged windows (browser.xhtml, browser console, ...)
 * - For all chrome *and* content contexts (privileged windows, as well as <browser> elements and their inner content documents)
 * - For all nested browsing context. We fetch the contexts recursively.
 *
 * @param {Object} sessionContext
 *        The Session Context to help know what is debugged.
 *        See devtools/server/actors/watcher/session-context.js
 * @param {Object} options
 *        Optional arguments passed via a dictionary.
 * @param {Boolean} options.acceptSameProcessIframes
 *        If true, we will accept WindowGlobal that runs in the same process as their parent document.
 *        That, even when EFT is disabled.
 */
export function getAllBrowsingContextsForContext(
  sessionContext,
  { acceptSameProcessIframes = false } = {}
) {
  const browsingContexts = [];

  // For a given BrowsingContext, add the `browsingContext`
  // all of its children, that, recursively.
  function walk(browsingContext) {
    if (browsingContexts.includes(browsingContext)) {
      return;
    }
    browsingContexts.push(browsingContext);

    for (const child of browsingContext.children) {
      walk(child);
    }

    if (
      (sessionContext.type == "all" || sessionContext.type == "webextension") &&
      browsingContext.window
    ) {
      // If the document is in the parent process, also iterate over each <browser>'s browsing context.
      // BrowsingContext.children doesn't cross chrome to content boundaries,
      // so we have to cross these boundaries by ourself.
      // (This is also the reason why we aren't using BrowsingContext.getAllBrowsingContextsInSubtree())
      for (const browser of browsingContext.window.document.querySelectorAll(
        `browser[type="content"]`
      )) {
        walk(browser.browsingContext);
      }
    }
  }

  // If target a single browser element, only walk through its BrowsingContext
  if (sessionContext.type == "browser-element") {
    const topBrowsingContext = BrowsingContext.getCurrentTopByBrowserId(
      sessionContext.browserId
    );
    // topBrowsingContext can be null if getCurrentTopByBrowserId is called for a tab that is unloaded.
    if (topBrowsingContext) {
      // Unfortunately, getCurrentTopByBrowserId is subject to race conditions and may refer to a BrowsingContext
      // that already navigated away.
      // Query the current "live" BrowsingContext by going through the embedder element (i.e. the <browser>/<iframe> element)
      // devtools/client/responsive/test/browser/browser_navigation.js covers this with fission enabled.
      const realTopBrowsingContext =
        topBrowsingContext.embedderElement.browsingContext;
      walk(realTopBrowsingContext);
    }
  } else if (
    sessionContext.type == "all" ||
    sessionContext.type == "webextension"
  ) {
    // For the browser toolbox and web extension, retrieve all possible BrowsingContext.
    // For WebExtension, we will then filter out the BrowsingContexts via `isBrowsingContextPartOfContext`.
    //
    // Fetch all top level window's browsing contexts
    for (const window of Services.ww.getWindowEnumerator()) {
      if (window.docShell.browsingContext) {
        walk(window.docShell.browsingContext);
      }
    }
  } else {
    throw new Error("Unsupported session context type: " + sessionContext.type);
  }

  return browsingContexts.filter(bc =>
    // We force accepting the top level browsing context, otherwise
    // it would only be returned if sessionContext.isServerSideTargetSwitching is enabled.
    isBrowsingContextPartOfContext(bc, sessionContext, {
      forceAcceptTopLevelTarget: true,
      acceptSameProcessIframes,
    })
  );
}

if (typeof module == "object") {
  module.exports = {
    isBrowsingContextPartOfContext,
    isWindowGlobalPartOfContext,
    getAddonIdForWindowGlobal,
    getAllBrowsingContextsForContext,
  };
}