"use strict"; const { createAppInfo } = AddonTestUtils; AddonTestUtils.init(this); createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); const server = createHttpServer(); server.registerDirectory("/data/", do_get_file("data")); const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; // A small utility function used to test the expected behaviors of the userScripts API method // wrapper. async function test_userScript_APIMethod({ apiScript, userScript, userScriptMetadata, testFn, runtimeMessageListener, }) { async function backgroundScript( userScriptFn, scriptMetadata, messageListener ) { await browser.userScripts.register({ js: [ { code: `(${userScriptFn})();`, }, ], runAt: "document_end", matches: ["http://localhost/*/file_sample.html"], scriptMetadata, }); if (messageListener) { browser.runtime.onMessage.addListener(messageListener); } browser.test.sendMessage("background-ready"); } function notifyFinish(failureReason) { browser.test.assertEq( undefined, failureReason, "should be completed without errors" ); browser.test.sendMessage("test_userScript_APIMethod:done"); } function assertTrue(val, message) { browser.test.assertTrue(val, message); if (!val) { browser.test.sendMessage("test_userScript_APIMethod:done"); throw message; } } let extension = ExtensionTestUtils.loadExtension({ manifest: { permissions: ["http://localhost/*/file_sample.html"], user_scripts: { api_script: "api-script.js", }, }, // Defines a background script that receives all the needed test parameters. background: ` const metadata = ${JSON.stringify(userScriptMetadata)}; (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener}) `, files: { "api-script.js": `(${apiScript})({ assertTrue: ${assertTrue}, notifyFinish: ${notifyFinish} })`, }, }); // Load a page in a content process, register the user script and then load a // new page in the existing content process. let url = `${BASE_URL}/file_sample.html`; let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); await extension.startup(); await extension.awaitMessage("background-ready"); await contentPage.loadURL(url); // Run any additional test-specific assertions. if (testFn) { await testFn({ extension, contentPage, url }); } await extension.awaitMessage("test_userScript_APIMethod:done"); await extension.unload(); await contentPage.close(); } add_task(async function test_apiScript_exports_simple_sync_method() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { const scriptMetadata = script.metadata; script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod( stringParam, numberParam, boolParam, nullParam, undefinedParam, arrayParam ) { browser.test.assertEq( "test-user-script-exported-apis", scriptMetadata.name, "Got the expected value for a string scriptMetadata property" ); browser.test.assertEq( null, scriptMetadata.nullProperty, "Got the expected value for a null scriptMetadata property" ); browser.test.assertTrue( scriptMetadata.arrayProperty && scriptMetadata.arrayProperty.length === 1 && scriptMetadata.arrayProperty[0] === "el1", "Got the expected value for an array scriptMetadata property" ); browser.test.assertTrue( scriptMetadata.objectProperty && scriptMetadata.objectProperty.nestedProp === "nestedValue", "Got the expected value for an object scriptMetadata property" ); browser.test.assertEq( "param1", stringParam, "Got the expected string parameter value" ); browser.test.assertEq( 123, numberParam, "Got the expected number parameter value" ); browser.test.assertEq( true, boolParam, "Got the expected boolean parameter value" ); browser.test.assertEq( null, nullParam, "Got the expected null parameter value" ); browser.test.assertEq( undefined, undefinedParam, "Got the expected undefined parameter value" ); browser.test.assertEq( 3, arrayParam.length, "Got the expected length on the array param" ); browser.test.assertTrue( arrayParam.includes(1), "Got the expected result when calling arrayParam.includes" ); return "returned_value"; }, }); }); } function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; // Redefine the includes method on the Array prototype, to explicitly verify that the method // redefined in the userScript is not used when accessing arrayParam.includes from the API script. // eslint-disable-next-line no-extend-native Array.prototype.includes = () => { throw new Error("Unexpected prototype leakage"); }; const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor const result = testAPIMethod( "param1", 123, true, null, undefined, arrayParam ); assertTrue( result === "returned_value", `userScript got an unexpected result value: ${result}` ); notifyFinish(); } const userScriptMetadata = { name: "test-user-script-exported-apis", arrayProperty: ["el1"], objectProperty: { nestedProp: "nestedValue" }, nullProperty: null, }; await test_userScript_APIMethod({ userScript, apiScript, userScriptMetadata, }); }); add_task(async function test_apiScript_async_method() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod(param, cb, cb2) { browser.test.assertEq( "function", typeof cb, "Got a callback function parameter" ); browser.test.assertTrue( cb === cb2, "Got the same cloned function for the same function parameter" ); browser.runtime.sendMessage(param).then(bgPageRes => { const cbResult = cb(script.export(bgPageRes)); browser.test.sendMessage("user-script-callback-return", cbResult); }); return "resolved_value"; }, }); }); } async function userScript() { // Redefine Promise to verify that it doesn't break the WebExtensions internals // that are going to use them. const { Promise } = this; Promise.resolve = function () { throw new Error("Promise.resolve poisoning"); }; this.Promise = function () { throw new Error("Promise constructor poisoning"); }; const { assertTrue, notifyFinish, testAPIMethod } = this; const cb = cbParam => { return `callback param: ${JSON.stringify(cbParam)}`; }; const cb2 = cb; const asyncAPIResult = await testAPIMethod("param3", cb, cb2); assertTrue( asyncAPIResult === "resolved_value", `userScript got an unexpected resolved value: ${asyncAPIResult}` ); notifyFinish(); } async function runtimeMessageListener(param) { if (param !== "param3") { browser.test.fail(`Got an unexpected message: ${param}`); } return { bgPageReply: true }; } await test_userScript_APIMethod({ userScript, apiScript, runtimeMessageListener, async testFn({ extension }) { const res = await extension.awaitMessage("user-script-callback-return"); equal( res, `callback param: ${JSON.stringify({ bgPageReply: true })}`, "Got the expected userScript callback return value" ); }, }); }); add_task(async function test_apiScript_method_with_webpage_objects_params() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod(windowParam, documentParam) { browser.test.assertEq( window, windowParam, "Got a reference to the native window as first param" ); browser.test.assertEq( window.document, documentParam, "Got a reference to the native document as second param" ); // Return an uncloneable webpage object, which checks that if the returned object is from a principal // that is subsumed by the userScript sandbox principal, it is returned without being cloned. return windowParam; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; const result = testAPIMethod(window, document); // We expect the returned value to be the uncloneable window object. assertTrue( result === window, `userScript got an unexpected returned value: ${result}` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); }); add_task(async function test_apiScript_method_got_param_with_methods() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { const scriptGlobal = script.global; const ScriptFunction = scriptGlobal.Function; script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod(objWithMethods) { browser.test.assertEq( "objPropertyValue", objWithMethods && objWithMethods.objProperty, "Got the expected property on the object passed as a parameter" ); browser.test.assertEq( undefined, objWithMethods?.objMethod, "XrayWrapper should deny access to a callable property" ); browser.test.assertTrue( objWithMethods && objWithMethods.wrappedJSObject && objWithMethods.wrappedJSObject.objMethod instanceof ScriptFunction.wrappedJSObject, "The callable property is accessible on the wrappedJSObject" ); browser.test.assertEq( "objMethodResult: p1", objWithMethods && objWithMethods.wrappedJSObject && objWithMethods.wrappedJSObject.objMethod("p1"), "Got the expected result when calling the method on the wrappedJSObject" ); return true; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; let result = testAPIMethod({ objProperty: "objPropertyValue", objMethod(param) { return `objMethodResult: ${param}`; }, }); assertTrue( result === true, `userScript got an unexpected returned value: ${result}` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); }); add_task(async function test_apiScript_method_throws_errors() { function apiScript({ notifyFinish }) { let proxyTrapsCount = 0; browser.userScripts.onBeforeScript.addListener(script => { const scriptGlobals = { Error: script.global.Error, TypeError: script.global.TypeError, Proxy: script.global.Proxy, }; script.defineGlobals({ notifyFinish, testAPIMethod(errorTestName, returnRejectedPromise) { let err; switch (errorTestName) { case "apiScriptError": err = new Error(`${errorTestName} message`); break; case "apiScriptThrowsPlainString": err = `${errorTestName} message`; break; case "apiScriptThrowsNull": err = null; break; case "userScriptError": err = new scriptGlobals.Error(`${errorTestName} message`); break; case "userScriptTypeError": err = new scriptGlobals.TypeError(`${errorTestName} message`); break; case "userScriptProxyObject": let proxyTarget = script.export({ name: "ProxyObject", message: "ProxyObject message", }); let proxyHandlers = script.export({ get(target, prop) { proxyTrapsCount++; switch (prop) { case "name": return "ProxyObjectGetName"; case "message": return "ProxyObjectGetMessage"; } return undefined; }, getPrototypeOf() { proxyTrapsCount++; return scriptGlobals.TypeError; }, }); err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers); break; default: browser.test.fail(`Unknown ${errorTestName} error testname`); return undefined; } if (returnRejectedPromise) { return Promise.reject(err); } throw err; }, assertNoProxyTrapTriggered() { browser.test.assertEq( 0, proxyTrapsCount, "Proxy traps should not be triggered" ); }, resetProxyTrapCounter() { proxyTrapsCount = 0; }, sendResults(results) { browser.test.sendMessage("test-results", results); }, }); }); } async function userScript() { const { assertNoProxyTrapTriggered, notifyFinish, resetProxyTrapCounter, sendResults, testAPIMethod, } = this; let apiThrowResults = {}; let apiThrowTestCases = [ "apiScriptError", "apiScriptThrowsPlainString", "apiScriptThrowsNull", "userScriptError", "userScriptTypeError", "userScriptProxyObject", ]; for (let errorTestName of apiThrowTestCases) { try { testAPIMethod(errorTestName); } catch (err) { // We expect that no proxy traps have been triggered by the WebExtensions internals. if (errorTestName === "userScriptProxyObject") { assertNoProxyTrapTriggered(); } if (err instanceof Error) { apiThrowResults[errorTestName] = { name: err.name, message: err.message, }; } else { apiThrowResults[errorTestName] = { name: err && err.name, message: err && err.message, typeOf: typeof err, value: err, }; } } } sendResults(apiThrowResults); resetProxyTrapCounter(); let apiRejectsResults = {}; for (let errorTestName of apiThrowTestCases) { try { await testAPIMethod(errorTestName, true); } catch (err) { // We expect that no proxy traps have been triggered by the WebExtensions internals. if (errorTestName === "userScriptProxyObject") { assertNoProxyTrapTriggered(); } if (err instanceof Error) { apiRejectsResults[errorTestName] = { name: err.name, message: err.message, }; } else { apiRejectsResults[errorTestName] = { name: err && err.name, message: err && err.message, typeOf: typeof err, value: err, }; } } } sendResults(apiRejectsResults); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, async testFn({ extension }) { const expectedResults = { // Any error not explicitly raised as a userScript objects or error instance is // expected to be turned into a generic error message. apiScriptError: { name: "Error", message: "An unexpected apiScript error occurred", }, // When the api script throws a primitive value, we expect to receive it unmodified on // the userScript side. apiScriptThrowsPlainString: { typeOf: "string", value: "apiScriptThrowsPlainString message", name: undefined, message: undefined, }, apiScriptThrowsNull: { typeOf: "object", value: null, name: undefined, message: undefined, }, // Error messages that the apiScript has explicitly created as userScript's Error // global instances are expected to be passing through unmodified. userScriptError: { name: "Error", message: "userScriptError message" }, userScriptTypeError: { name: "TypeError", message: "userScriptTypeError message", }, // Error raised from the apiScript as userScript proxy objects are expected to // be passing through unmodified. userScriptProxyObject: { typeOf: "object", name: "ProxyObjectGetName", message: "ProxyObjectGetMessage", }, }; info( "Checking results from errors raised from an apiScript exported function" ); const apiThrowResults = await extension.awaitMessage("test-results"); for (let [key, expected] of Object.entries(expectedResults)) { Assert.deepEqual( apiThrowResults[key], expected, `Got the expected error object for test case "${key}"` ); } Assert.deepEqual( Object.keys(expectedResults).sort(), Object.keys(apiThrowResults).sort(), "the expected and actual test case names matches" ); info( "Checking expected results from errors raised from an apiScript exported function" ); // Verify expected results from rejected promises returned from an apiScript exported function. const apiThrowRejections = await extension.awaitMessage("test-results"); for (let [key, expected] of Object.entries(expectedResults)) { Assert.deepEqual( apiThrowRejections[key], expected, `Got the expected rejected object for test case "${key}"` ); } Assert.deepEqual( Object.keys(expectedResults).sort(), Object.keys(apiThrowRejections).sort(), "the expected and actual test case names matches" ); }, }); }); add_task( async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod(...args) { // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object // is supposed to be Object.prototype. browser.test.assertEq( script.global.Object.prototype, Object.getPrototypeOf(args[0]), "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap" ); browser.test.assertTrue( Array.isArray(args[0]), "Got an array object for the XrayWrapped proxy object param" ); browser.test.assertEq( undefined, args[0].length, "XrayWrappers deny access to the length property" ); browser.test.assertEq( undefined, args[0][0], "Got the expected item in the array object" ); return true; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; let proxy = new Proxy(["expectedArrayValue"], { getPrototypeOf() { throw new Error("Proxy's getPrototypeOf trap"); }, get() { throw new Error("Proxy's get trap"); }, }); let result = testAPIMethod(proxy); assertTrue( result, `userScript got an unexpected returned value: ${result}` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); } ); add_task(async function test_apiScript_method_return_proxy_object() { function apiScript(sharedTestAPIMethods) { let proxyTrapsCount = 0; let scriptTrapsCount = 0; browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethodError() { return new Proxy(["expectedArrayValue"], { getPrototypeOf(target) { proxyTrapsCount++; return Object.getPrototypeOf(target); }, }); }, testAPIMethodOk() { return new script.global.Proxy( script.export(["expectedArrayValue"]), script.export({ getPrototypeOf(target) { scriptTrapsCount++; return script.global.Object.getPrototypeOf(target); }, }) ); }, assertNoProxyTrapTriggered() { browser.test.assertEq( 0, proxyTrapsCount, "Proxy traps should not be triggered" ); }, assertScriptProxyTrapsCount(expected) { browser.test.assertEq( expected, scriptTrapsCount, "Script Proxy traps should have been triggered" ); }, }); }); } async function userScript() { const { assertTrue, assertNoProxyTrapTriggered, assertScriptProxyTrapsCount, notifyFinish, testAPIMethodError, testAPIMethodOk, } = this; let error; try { let result = testAPIMethodError(); notifyFinish( `Unexpected returned value while expecting error: ${result}` ); return; } catch (err) { error = err; } assertTrue( error && error.message.includes("Return value not accessible to the userScript"), `Got an unexpected error message: ${error}` ); error = undefined; try { let result = testAPIMethodOk(); assertScriptProxyTrapsCount(0); if (!(result instanceof Array)) { notifyFinish(`Got an unexpected result: ${result}`); return; } assertScriptProxyTrapsCount(1); } catch (err) { error = err; } assertTrue(!error, `Got an unexpected error: ${error}`); assertNoProxyTrapTriggered(); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); }); add_task(async function test_apiScript_returns_functions() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIReturnsFunction() { // Return a function with provides the same kind of behavior // of the API methods exported as globals. return script.export(() => window); }, testAPIReturnsObjWithMethod() { return script.export({ getWindow() { return window; }, }); }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIReturnsFunction, testAPIReturnsObjWithMethod, } = this; let resultFn = testAPIReturnsFunction(); assertTrue( typeof resultFn === "function", `userScript got an unexpected returned value: ${typeof resultFn}` ); let fnRes = resultFn(); assertTrue( fnRes === window, `Got an unexpected value from the returned function: ${fnRes}` ); let resultObj = testAPIReturnsObjWithMethod(); let actualTypeof = resultObj && typeof resultObj.getWindow; assertTrue( actualTypeof === "function", `Returned object does not have the expected getWindow method: ${actualTypeof}` ); let methodRes = resultObj.getWindow(); assertTrue( methodRes === window, `Got an unexpected value from the returned method: ${methodRes}` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); }); add_task( async function test_apiScript_method_clone_non_subsumed_returned_values() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethodReturnOk() { return script.export({ objKey1: { nestedProp: "nestedvalue", }, window, }); }, testAPIMethodExplicitlyClonedError() { let result = script.export({ apiScopeObject: undefined }); browser.test.assertThrows( () => { result.apiScopeObject = { disallowedProp: "disallowedValue" }; }, /Not allowed to define cross-origin object as property on .* XrayWrapper/, "Assigning a property to a xRayWrapper is expected to throw" ); // Let the exception to be raised, so that we check that the actual underlying // error message is not leaking in the userScript (replaced by the generic // "An unexpected apiScript error occurred" error message). result.apiScopeObject = { disallowedProp: "disallowedValue" }; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethodReturnOk, testAPIMethodExplicitlyClonedError, } = this; let result = testAPIMethodReturnOk(); assertTrue( result && "objKey1" in result && result.objKey1.nestedProp === "nestedvalue", `userScript got an unexpected returned value: ${result}` ); assertTrue( result.window === window, `userScript should have access to the window property: ${result.window}` ); let error; try { result = testAPIMethodExplicitlyClonedError(); notifyFinish( `Unexpected returned value while expecting error: ${result}` ); return; } catch (err) { error = err; } // We expect the generic "unexpected apiScript error occurred" to be raised to the // userScript code. assertTrue( error && error.message.includes("An unexpected apiScript error occurred"), `Got an unexpected error message: ${error}` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); } ); add_task(async function test_apiScript_method_export_primitive_types() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod(typeToExport) { switch (typeToExport) { case "boolean": return script.export(true); case "number": return script.export(123); case "string": return script.export("a string"); case "symbol": return script.export(Symbol("a symbol")); } return undefined; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; let v = testAPIMethod("boolean"); assertTrue(v === true, `Should export a boolean`); v = testAPIMethod("number"); assertTrue(v === 123, `Should export a number`); v = testAPIMethod("string"); assertTrue(v === "a string", `Should export a string`); v = testAPIMethod("symbol"); assertTrue(typeof v === "symbol", `Should export a symbol`); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); }); add_task( async function test_apiScript_method_avoid_unnecessary_params_cloning() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethodReturnsParam(param) { return param; }, testAPIMethodReturnsUnwrappedParam(param) { return param.wrappedJSObject; }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethodReturnsParam, testAPIMethodReturnsUnwrappedParam, } = this; let obj = {}; let result = testAPIMethodReturnsParam(obj); assertTrue( result === obj, `Expect returned value to be strictly equal to the API method parameter` ); result = testAPIMethodReturnsUnwrappedParam(obj); assertTrue( result === obj, `Expect returned value to be strictly equal to the unwrapped API method parameter` ); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); } ); add_task(async function test_apiScript_method_export_sparse_arrays() { function apiScript(sharedTestAPIMethods) { browser.userScripts.onBeforeScript.addListener(script => { script.defineGlobals({ ...sharedTestAPIMethods, testAPIMethod() { const sparseArray = []; sparseArray[3] = "third-element"; sparseArray[5] = "fifth-element"; return script.export(sparseArray); }, }); }); } async function userScript() { const { assertTrue, notifyFinish, testAPIMethod } = this; const result = testAPIMethod(window, document); // We expect the returned value to be the uncloneable window object. assertTrue( result && result.length === 6, `the returned value should be an array of the expected length: ${result}` ); assertTrue( result[3] === "third-element", `the third array element should have the expected value: ${result[3]}` ); assertTrue( result[5] === "fifth-element", `the fifth array element should have the expected value: ${result[5]}` ); assertTrue( result[0] === undefined, `the first array element should have the expected value: ${result[0]}` ); assertTrue(!("0" in result), "Holey array should still be holey"); notifyFinish(); } await test_userScript_APIMethod({ userScript, apiScript, }); });