summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/child/ext-userScripts-content.js
blob: d2edb3c5153247dd0ab305349eeb144ef0d8d182 (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
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */

"use strict";

var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled";
var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`;

ChromeUtils.defineESModuleGetters(this, {
  Schemas: "resource://gre/modules/Schemas.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "userScriptsEnabled",
  USERSCRIPT_PREFNAME,
  false
);

var { ExtensionError } = ExtensionUtils;

const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"];

/**
 * Represents a user script in the child content process.
 *
 * This class implements the API object that is passed as a parameter to the
 * browser.userScripts.onBeforeScript API Event.
 *
 * @param {object} params
 * @param {ContentScriptContextChild} params.context
 *        The context which has registered the userScripts.onBeforeScript listener.
 * @param {PlainJSONValue}            params.metadata
 *        An opaque user script metadata value (as set in userScripts.register).
 * @param {Sandbox}                   params.scriptSandbox
 *        The Sandbox object of the userScript.
 */
class UserScript {
  constructor({ context, metadata, scriptSandbox }) {
    this.context = context;
    this.extension = context.extension;
    this.apiSandbox = context.cloneScope;
    this.metadata = metadata;
    this.scriptSandbox = scriptSandbox;

    this.ScriptError = scriptSandbox.Error;
    this.ScriptPromise = scriptSandbox.Promise;
  }

  /**
   * Returns the API object provided to the userScripts.onBeforeScript listeners.
   *
   * @returns {object}
   *          The API object with the properties and methods to export
   *          to the extension code.
   */
  api() {
    return {
      metadata: this.metadata,
      defineGlobals: sourceObject => this.defineGlobals(sourceObject),
      export: value => this.export(value),
    };
  }

  /**
   * Define all the properties of a given plain object as lazy getters of the
   * userScript global object.
   *
   * @param {object} sourceObject
   *        A set of objects and methods to export into the userScript scope as globals.
   *
   * @throws {context.Error}
   *         Throws an apiScript error when sourceObject is not a plain object.
   */
  defineGlobals(sourceObject) {
    let className;
    try {
      className = ChromeUtils.getClassName(sourceObject, true);
    } catch (e) {
      // sourceObject is not an object;
    }

    if (className !== "Object") {
      throw new this.context.Error(
        "Invalid sourceObject type, plain object expected."
      );
    }

    this.exportLazyGetters(sourceObject, this.scriptSandbox);
  }

  /**
   * Convert a given value to make it accessible to the userScript code.
   *
   * - any property value that is already accessible to the userScript code is returned unmodified by
   *   the lazy getter
   * - any apiScript's Function is wrapped using the `wrapFunction` method
   * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its
   *   properties).
   *
   * @param {any} valueToExport
   *        A value to convert into an object accessible to the userScript.
   *
   * @param {object} privateOptions
   *        A set of options used when this method is called internally (not exposed in the
   *        api object exported to the onBeforeScript listeners).
   * @param {Error}  privateOptions.Error
   *        The Error constructor to use to report errors (defaults to the apiScript context's Error
   *        when missing).
   * @param {Error}  privateOptions.errorMessage
   *        A custom error message to report exporting error on values not allowed.
   *
   * @returns {any}
   *        The resulting userScript object.
   *
   * @throws {context.Error | privateOptions.Error}
   *         Throws an error when the value is not allowed and it can't be exported into an allowed one.
   */
  export(valueToExport, privateOptions = {}) {
    const ExportError = privateOptions.Error || this.context.Error;

    if (this.canAccess(valueToExport, this.scriptSandbox)) {
      // Return the value unmodified if the userScript principal is already allowed
      // to access it.
      return valueToExport;
    }

    let className;

    try {
      className = ChromeUtils.getClassName(valueToExport, true);
    } catch (e) {
      // sourceObject is not an object;
    }

    if (className === "Function") {
      return this.wrapFunction(valueToExport);
    }

    if (className === "Object") {
      return this.exportLazyGetters(valueToExport);
    }

    if (className === "Array") {
      return this.exportArray(valueToExport);
    }

    let valueType = className || typeof valueToExport;
    throw new ExportError(
      privateOptions.errorMessage ||
        `${valueType} cannot be exported to the userScript`
    );
  }

  /**
   * Export all the elements of the `srcArray` into a newly created userScript array.
   *
   * @param {Array} srcArray
   *        The apiScript array to export to the userScript code.
   *
   * @returns {Array}
   *          The resulting userScript array.
   *
   * @throws {UserScriptError}
   *         Throws an error when the array can't be exported successfully.
   */
  exportArray(srcArray) {
    const destArray = Cu.cloneInto([], this.scriptSandbox);

    for (let [idx, value] of this.shallowCloneEntries(srcArray)) {
      destArray[idx] = this.export(value, {
        errorMessage: `Error accessing disallowed element at index "${idx}"`,
        Error: this.UserScriptError,
      });
    }

    return destArray;
  }

  /**
   * Export all the properties of the `src` plain object as lazy getters on the `dest` object,
   * or in a newly created userScript object if `dest` is `undefined`.
   *
   * @param {object} src
   *        A set of properties to define on a `dest` object as lazy getters.
   * @param {object} [dest]
   *        An optional `dest` object (a new userScript object is created by default when not specified).
   *
   * @returns {object}
   *          The resulting userScript object.
   */
  exportLazyGetters(src, dest = undefined) {
    dest = dest || Cu.createObjectIn(this.scriptSandbox);

    for (let [key, value] of this.shallowCloneEntries(src)) {
      Schemas.exportLazyGetter(dest, key, () => {
        return this.export(value, {
          // Lazy properties will raise an error for properties with not allowed
          // values to the userScript scope, and so we have to raise an userScript
          // Error here.
          Error: this.ScriptError,
          errorMessage: `Error accessing disallowed property "${key}"`,
        });
      });
    }

    return dest;
  }

  /**
   * Export and wrap an apiScript function to provide the following behaviors:
   *   - errors throws from an exported function are checked by `handleAPIScriptError`
   *   - returned apiScript's Promises (not accessible to the userScript) are converted into a
   *     userScript's Promise
   *   - check if the returned or resolved value is accessible to the userScript code
   *     (and raise a userScript error if it is not)
   *
   * @param {Function} fn
   *        The apiScript function to wrap
   *
   * @returns {object}
   *          The resulting userScript function.
   */
  wrapFunction(fn) {
    return Cu.exportFunction((...args) => {
      let res;
      try {
        // Checks that all the elements in the `...args` array are allowed to be
        // received from the apiScript.
        for (let arg of args) {
          if (!this.canAccess(arg, this.apiSandbox)) {
            throw new this.ScriptError(
              `Parameter not accessible to the userScript API`
            );
          }
        }

        res = fn(...args);
      } catch (err) {
        this.handleAPIScriptError(err);
      }

      // Prevent execution of proxy traps while checking if the return value is a Promise.
      if (!Cu.isProxy(res) && res instanceof this.context.Promise) {
        return this.ScriptPromise.resolve().then(async () => {
          let value;

          try {
            value = await res;
          } catch (err) {
            this.handleAPIScriptError(err);
          }

          return this.ensureAccessible(value);
        });
      }

      return this.ensureAccessible(res);
    }, this.scriptSandbox);
  }

  /**
   * Shallow clone the source object and iterate over its Object properties (or Array elements),
   * which allow us to safely iterate over all its properties (including callable objects that
   * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g.
   * getters).
   *
   * @param {object | Array} obj
   *        The Object or Array object to shallow clone and iterate over.
   */
  *shallowCloneEntries(obj) {
    const clonedObj = ChromeUtils.shallowClone(obj);

    for (let entry of Object.entries(clonedObj)) {
      yield entry;
    }
  }

  /**
   * Check if the given value is accessible to the targetScope.
   *
   * @param {any}     val
   *        The value to check.
   * @param {Sandbox} targetScope
   *        The targetScope that should be able to access the value.
   *
   * @returns {boolean}
   */
  canAccess(val, targetScope) {
    if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) {
      return true;
    }

    // Disallow objects that are coming from principals that are not
    // subsumed by the targetScope's principal.
    try {
      const targetPrincipal = Cu.getObjectPrincipal(targetScope);
      if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) {
        return false;
      }
    } catch (err) {
      Cu.reportError(err);
      return false;
    }

    return true;
  }

  /**
   * Check if the value returned (or resolved) from an apiScript method is accessible
   * to the userScript code, and throw a userScript Error if it is not allowed.
   *
   * @param {any} res
   *        The value to return/resolve.
   *
   * @returns {any}
   *          The exported value.
   *
   * @throws {Error}
   *         Throws a userScript error when the value is not accessible to the userScript scope.
   */
  ensureAccessible(res) {
    if (this.canAccess(res, this.scriptSandbox)) {
      return res;
    }

    throw new this.ScriptError("Return value not accessible to the userScript");
  }

  /**
   * Handle the error raised (and rejected promise returned) from apiScript functions exported to the
   * userScript.
   *
   * @param {any} err
   *        The value to return/resolve.
   *
   * @throws {any}
   *         This method is expected to throw:
   *         - any value that is already accessible to the userScript code is forwarded unmodified
   *         - any value that is not accessible to the userScript code is logged in the console
   *           (to make it easier to investigate the underlying issue) and converted into a
   *           userScript Error (with the generic "An unexpected apiScript error occurred" error
   *           message accessible to the userScript)
   */
  handleAPIScriptError(err) {
    if (this.canAccess(err, this.scriptSandbox)) {
      throw err;
    }

    // Log the actual error on the console and raise a generic userScript Error
    // on error objects that can't be accessed by the UserScript principal.
    try {
      const debugName = this.extension.policy.debugName;
      Cu.reportError(
        `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}`
      );
    } catch (e) {}

    throw new this.ScriptError(`An unexpected apiScript error occurred`);
  }
}

this.userScriptsContent = class extends ExtensionAPI {
  getAPI(context) {
    return {
      userScripts: {
        onBeforeScript: new EventManager({
          context,
          name: "userScripts.onBeforeScript",
          register: fire => {
            if (!userScriptsEnabled) {
              throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG);
            }

            let handler = (event, metadata, scriptSandbox) => {
              const us = new UserScript({
                context,
                metadata,
                scriptSandbox,
              });

              const apiObj = Cu.cloneInto(us.api(), context.cloneScope, {
                cloneFunctions: true,
              });

              Object.defineProperty(apiObj, "global", {
                value: scriptSandbox,
                enumerable: true,
                configurable: true,
                writable: true,
              });

              fire.raw(apiObj);
            };

            context.userScriptsEvents.on("on-before-script", handler);
            return () => {
              context.userScriptsEvents.off("on-before-script", handler);
            };
          },
        }).api(),
      },
    };
  }
};