589 lines
21 KiB
HTML
589 lines
21 KiB
HTML
<!--
|
|
Any copyright is dedicated to the Public Domain.
|
|
http://creativecommons.org/publicdomain/zero/1.0/
|
|
-->
|
|
<!DOCTYPE HTML>
|
|
<html>
|
|
<head>
|
|
<title>Test for 3rd party imported script and muted errors</title>
|
|
<script src="/tests/SimpleTest/SimpleTest.js"></script>
|
|
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
|
|
</head>
|
|
<body>
|
|
<script type="text/javascript">
|
|
|
|
const workerURL = 'http://mochi.test:8888/tests/dom/workers/test/importScripts_3rdParty_worker.js';
|
|
|
|
const sameOriginBaseURL = 'http://mochi.test:8888/tests/dom/workers/test';
|
|
const crossOriginBaseURL = "https://example.com/tests/dom/workers/test";
|
|
|
|
const workerRelativeUrl = 'importScripts_3rdParty_worker.js';
|
|
const workerAbsoluteUrl = `${sameOriginBaseURL}/${workerRelativeUrl}`
|
|
|
|
/**
|
|
* This file tests cross-origin error muting in importScripts for workers. In
|
|
* particular, we want to test:
|
|
* - The errors thrown by the parsing phase of importScripts().
|
|
* - The errors thrown by the top-level evaluation phase of importScripts().
|
|
* - If the error is reported to the parent's Worker binding, including through
|
|
* nested workers, as well as the contents of the error.
|
|
* - For errors:
|
|
* - What type of exception is reported?
|
|
* - What fileName is reported on the exception?
|
|
* - What are the contents of the stack on the exception?
|
|
*
|
|
* Relevant specs:
|
|
* - https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-worker-imported-script
|
|
* - https://html.spec.whatwg.org/multipage/webappapis.html#creating-a-classic-script
|
|
*
|
|
* The situation and motivation for error muting is:
|
|
* - JS scripts are allowed to be loaded cross-origin without CORS for legacy
|
|
* reasons. If a script is cross-origin, its "muted errors" is set to true.
|
|
* - The fetch will set the "use-URL-credentials" flag
|
|
* https://fetch.spec.whatwg.org/#concept-request-use-url-credentials-flag
|
|
* but will have the default "credentials" mode of "omit"
|
|
* https://fetch.spec.whatwg.org/#concept-request-credentials-mode which
|
|
* means that username/password will be propagated.
|
|
* - For legacy reasons, JS scripts aren't required to have an explicit JS MIME
|
|
* type which allows attacks that attempt to load a known-non JS file as JS
|
|
* in order to derive information from the errors or from side-effects to the
|
|
* global for code that does parse and evaluate as legal JS.
|
|
**/
|
|
|
|
|
|
/**
|
|
* - `sameOrigin`: Describes the exception we expect to see for a same-origin
|
|
* import.
|
|
* - `crossOrigin`: Describes the exception we expect to see for a cross-origin
|
|
* import (from example.com while the worker is the mochitest origin).
|
|
*
|
|
* The exception fields are:
|
|
* - `exceptionName`: The `name` of the Error object.
|
|
* - `thrownFile`: Describes the filename we expect to see on the error:
|
|
* - `importing-worker-script`: The worker script that's doing the importing
|
|
* will be the source of the exception, not the imported script.
|
|
* - `imported-script-no-redirect`: The (absolute-ified) script as passed to
|
|
* importScript(s), regardless of any redirects that occur.
|
|
* - `post-redirect-imported-script`: The name of the actual URL that was
|
|
* loaded following any redirects.
|
|
*/
|
|
const scriptPermutations = [
|
|
{
|
|
name: 'Invalid script that generates a syntax error',
|
|
script: 'invalid.js',
|
|
sameOrigin: {
|
|
exceptionName: 'SyntaxError',
|
|
thrownFile: 'post-redirect-imported-script',
|
|
isDOMException: false,
|
|
message: "expected expression, got end of script"
|
|
},
|
|
crossOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
}
|
|
},
|
|
{
|
|
name: 'Non-JS MIME Type',
|
|
script: 'mime_type_is_csv.js',
|
|
sameOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
},
|
|
crossOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
}
|
|
},
|
|
{
|
|
// What happens if the script is a 404?
|
|
name: 'Nonexistent script',
|
|
script: 'script_does_not_exist.js',
|
|
sameOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
},
|
|
crossOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
}
|
|
},
|
|
{
|
|
name: 'Script that throws during toplevel execution',
|
|
script: 'toplevel_throws.js',
|
|
sameOrigin: {
|
|
exceptionName: 'Error',
|
|
thrownFile: 'post-redirect-imported-script',
|
|
isDOMException: false,
|
|
message: "Toplevel-Throw-Payload",
|
|
},
|
|
crossOrigin: {
|
|
exceptionName: 'NetworkError',
|
|
thrownFile: 'importing-worker-script',
|
|
isDOMException: true,
|
|
code: DOMException.NETWORK_ERR,
|
|
message: "A network error occurred."
|
|
}
|
|
},
|
|
{
|
|
name: 'Script that exposes a method that throws',
|
|
script: 'call_throws.js',
|
|
sameOrigin: {
|
|
exceptionName: 'Error',
|
|
thrownFile: 'post-redirect-imported-script',
|
|
isDOMException: false,
|
|
message: "Method-Throw-Payload"
|
|
},
|
|
crossOrigin: {
|
|
exceptionName: 'Error',
|
|
thrownFile: 'imported-script-no-redirect',
|
|
isDOMException: false,
|
|
message: "Method-Throw-Payload"
|
|
}
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Special fields:
|
|
* - `transformScriptImport`: A function that takes the script name as input and
|
|
* produces the actual path to use for import purposes, allowing the addition
|
|
* of a redirect.
|
|
* - `expectedURLAfterRedirect`: A function that takes the script name as
|
|
* input and produces the expected script name post-redirect (if there is a
|
|
* redirect). In particular, our `redirect_with_query_args.sjs` helper will
|
|
* perform a same-origin redirect and append "?SECRET_DATA" onto the end of
|
|
* the redirected URL at this time.
|
|
* - `partOfTheURLToNotExposeToJS`: A string snippet that is present in the
|
|
* post-redirect contents that should absolutely not show up in the error's
|
|
* stack if the redirect isn't exposed. This is a secondary check to the
|
|
* result of expectedURLAfterRedirect.
|
|
*/
|
|
const urlPermutations = [
|
|
{
|
|
name: 'No Redirect',
|
|
transformScriptImport: x => x,
|
|
expectedURLAfterRedirect: x => x,
|
|
// No redirect means nothing to be paranoid about.
|
|
partOfTheURLToNotExposeToJS: null,
|
|
},
|
|
{
|
|
name: 'Same-Origin Redirect With Query Args',
|
|
// We mangle the script into uppercase and the redirector undoes this in
|
|
// order to minimize the similarity of the pre-redirect and post-redirect
|
|
// strings.
|
|
transformScriptImport: x => `redirect_with_query_args.sjs?${x.toUpperCase()}`,
|
|
expectedURLAfterRedirect: x => `${x}?SECRET_DATA`,
|
|
// The redirect will add this when it formulates the redirected URL, and the
|
|
// test wants to make sure this doesn't show up in filenames or stacks
|
|
// unless the thrownFile is set to 'post-redirect-imported-script'.
|
|
partOfTheURLToNotExposeToJS: 'SECRET_DATA',
|
|
}
|
|
];
|
|
const nestedPermutations = [
|
|
{
|
|
name: 'Window Parent',
|
|
nested: false,
|
|
},
|
|
{
|
|
name: 'Worker Parent',
|
|
nested: true,
|
|
}
|
|
];
|
|
|
|
// NOTE: These implementations are copied from importScripts_3rdParty_worker.js
|
|
// for reasons of minimizing the number of calls to importScripts for
|
|
// debugging.
|
|
function normalizeError(err) {
|
|
if (!err) {
|
|
return null;
|
|
}
|
|
|
|
const isDOMException = "filename" in err;
|
|
|
|
return {
|
|
message: err.message,
|
|
name: err.name,
|
|
isDOMException,
|
|
code: err.code,
|
|
// normalize to fileName
|
|
fileName: isDOMException ? err.filename : err.fileName,
|
|
hasFileName: !!err.fileName,
|
|
hasFilename: !!err.filename,
|
|
lineNumber: err.lineNumber,
|
|
columnNumber: err.columnNumber,
|
|
stack: err.stack,
|
|
stringified: err.toString(),
|
|
};
|
|
}
|
|
|
|
function normalizeErrorEvent(event) {
|
|
if (!event) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
message: event.message,
|
|
filename: event.filename,
|
|
lineno: event.lineno,
|
|
colno: event.colno,
|
|
error: normalizeError(event.error),
|
|
stringified: event.toString(),
|
|
};
|
|
}
|
|
// End duplicated code.
|
|
|
|
|
|
/**
|
|
* Validate the received error against our expectations and provided context.
|
|
*
|
|
* For `expectation`, see the `scriptPermutations` doc-block which documents
|
|
* its `sameOrigin` and `crossOrigin` properties which are what we expect here.
|
|
*
|
|
* The `context` should include:
|
|
* - `workerUrl`: The absolute URL of the toplevel worker script that the worker
|
|
* is running which is the code that calls `importScripts`.
|
|
* - `importUrl`: The absolute URL provided to the call to `importScripts`.
|
|
* This is the pre-redirect URL if a redirect is involved.
|
|
* - `postRedirectUrl`: The same as `importUrl` unless a redirect is involved,
|
|
* in which case this will be a different URL.
|
|
* - `isRedirected`: Boolean indicating whether a redirect was involved. This
|
|
* is a convenience variable that's derived from the above 2 URL's for now.
|
|
* - `shouldNotInclude`: Provided by the URL permutation, this is used to check
|
|
* that post-redirect data does not creep into the exception unless the
|
|
* expected `thrownFile` is `post-redirect-imported-script`.
|
|
*/
|
|
function checkError(label, expectation, context, err) {
|
|
info(`## Checking error: ${JSON.stringify(err)}`);
|
|
is(err.name, expectation.exceptionName,
|
|
`${label}: Error name matches "${expectation.exceptionName}"?`);
|
|
is(err.isDOMException, expectation.isDOMException,
|
|
`${label}: Is a DOM Exception == ${expectation.isDOMException}?`);
|
|
if (expectation.code) {
|
|
is(err.code, expectation.code,
|
|
`${label}: Code matches ${expectation.code}?`);
|
|
}
|
|
|
|
let expectedFile;
|
|
switch (expectation.thrownFile) {
|
|
case 'importing-worker-script':
|
|
expectedFile = context.workerUrl;
|
|
break;
|
|
case 'imported-script-no-redirect':
|
|
expectedFile = context.importUrl;
|
|
break;
|
|
case 'post-redirect-imported-script':
|
|
expectedFile = context.postRedirectUrl;
|
|
break;
|
|
default:
|
|
ok(false, `Unexpected thrownFile parameter: ${expectation.thrownFile}`);
|
|
return;
|
|
}
|
|
|
|
is(err.fileName, expectedFile,
|
|
`${label}: Filename from ${expectation.thrownFile} is ${expectedFile}`);
|
|
|
|
|
|
let expMessage = expectation.message;
|
|
if (typeof(expMessage) === "function") {
|
|
expMessage = expectation.message(context);
|
|
}
|
|
is(err.message, expMessage,
|
|
`${label}: Message is ${expMessage}`);
|
|
|
|
// If this is a redirect and we expect the error to not be surfacing any
|
|
// post-redirect information and there's a `shouldNotInclude` string, then
|
|
// check to make sure it's not present.
|
|
if (context.isRedirected && context.shouldNotInclude) {
|
|
if (expectation.thrownFile !== 'post-redirect-imported-script') {
|
|
ok(!err.stack.includes(context.shouldNotInclude),
|
|
`${label}: Stack should not include ${context.shouldNotInclude}:\n${err.stack}`);
|
|
ok(!err.stringified.includes(context.shouldNotInclude),
|
|
`${label}: Stringified error should not include ${context.shouldNotInclude}:\n${err.stringified}`);
|
|
} else if (expectation.exceptionName !== 'SyntaxError') {
|
|
// We do expect the shouldNotInclude to be present for
|
|
// 'post-redirect-imported-script' as long as the exception isn't a
|
|
// SyntaxError. SyntaxError stacks inherently do not include the filename
|
|
// of the file with the syntax problem as a stack frame.
|
|
ok(err.stack.includes(context.shouldNotInclude),
|
|
`${label}: Stack should include ${context.shouldNotInclude}:\n${err.stack}`);
|
|
}
|
|
}
|
|
let expStringified = `${err.name}: ${expMessage}`;
|
|
is(err.stringified, expStringified,
|
|
`${label}: Stringified error should be: ${expStringified}`);
|
|
|
|
// Add some whitespace in our output.
|
|
info("");
|
|
}
|
|
|
|
function checkErrorEvent(label, expectation, context, event, viaTask=false) {
|
|
info(`## Checking error event: ${JSON.stringify(event)}`);
|
|
|
|
let expectedFile;
|
|
switch (expectation.thrownFile) {
|
|
case 'importing-worker-script':
|
|
expectedFile = context.workerUrl;
|
|
break;
|
|
case 'imported-script-no-redirect':
|
|
expectedFile = context.importUrl;
|
|
break;
|
|
case 'post-redirect-imported-script':
|
|
expectedFile = context.postRedirectUrl;
|
|
break;
|
|
default:
|
|
ok(false, `Unexpected thrownFile parameter: ${expectation.thrownFile}`);
|
|
return;
|
|
}
|
|
|
|
is(event.filename, expectedFile,
|
|
`${label}: Filename from ${expectation.thrownFile} is ${expectedFile}`);
|
|
|
|
let expMessage = expectation.message;
|
|
if (typeof(expMessage) === "function") {
|
|
expMessage = expectation.message(context);
|
|
}
|
|
// The error event message prepends the exception name to the Error's message.
|
|
expMessage = `${expectation.exceptionName}: ${expMessage}`;
|
|
|
|
is(event.message, expMessage,
|
|
`${label}: Message is ${expMessage}`);
|
|
|
|
// If this is a redirect and we expect the error to not be surfacing any
|
|
// post-redirect information and there's a `shouldNotInclude` string, then
|
|
// check to make sure it's not present.
|
|
//
|
|
// Note that `stringified` may not be present for the "onerror" case.
|
|
if (context.isRedirected &&
|
|
expectation.thrownFile !== 'post-redirect-imported-script' &&
|
|
context.shouldNotInclude &&
|
|
event.stringified) {
|
|
ok(!event.stringified.includes(context.shouldNotInclude),
|
|
`${label}: Stringified error should not include ${context.shouldNotInclude}:\n${event.stringified}`);
|
|
}
|
|
if (event.stringified) {
|
|
is(event.stringified, "[object ErrorEvent]",
|
|
`${label}: Stringified event should be "[object ErrorEvent]"`);
|
|
}
|
|
|
|
// If we received the error via a task queued because it was not handled in
|
|
// the worker, then per
|
|
// https://html.spec.whatwg.org/multipage/workers.html#runtime-script-errors-2
|
|
// the error will be null.
|
|
if (viaTask) {
|
|
is(event.error, null,
|
|
`${label}: Error is null because it came from an HTML 10.2.5 task.`);
|
|
} else {
|
|
checkError(label, expectation, context, event.error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to spawn a worker, postMessage it the given args, and return the
|
|
* worker's response payload and the first "error" received on the Worker
|
|
* binding by the time the message handler resolves. The worker logic makes
|
|
* sure to delay its postMessage using setTimeout(0) so error events will always
|
|
* arrive before any message that is sent.
|
|
*
|
|
* If args includes a truthy `nested` value, then the `message` and
|
|
* `bindingErrorEvent` are as perceived by the parent worker.
|
|
*/
|
|
function asyncWorkerImport(args) {
|
|
const worker = new Worker(workerRelativeUrl);
|
|
const promise = new Promise((resolve, reject) => {
|
|
// The first "error" received on the Worker binding.
|
|
let firstErrorEvent = null;
|
|
|
|
worker.onmessage = function(event) {
|
|
let message = event.data;
|
|
// For the nested case, unwrap and normalize things.
|
|
if (args.nested) {
|
|
firstErrorEvent = message.errorEvent;
|
|
message = message.nestedMessage;
|
|
// We need to re-set the argument to be nested because it was set to
|
|
// false so that only a single level of nesting occurred.
|
|
message.args.nested = true;
|
|
}
|
|
|
|
// Make sure the args we receive from the worker are the same as the ones
|
|
// we sent.
|
|
is(JSON.stringify(message.args), JSON.stringify(args),
|
|
"Worker re-transmitted args match sent args.");
|
|
|
|
resolve({
|
|
message,
|
|
bindingErrorEvent: firstErrorEvent
|
|
});
|
|
worker.terminate();
|
|
};
|
|
worker.onerror = function(event) {
|
|
// We don't want this to bubble to the window and cause a test failure.
|
|
event.preventDefault();
|
|
|
|
if (firstErrorEvent) {
|
|
ok(false, "Worker binding received more than one error");
|
|
reject(new Error("multiple error events received"));
|
|
return;
|
|
}
|
|
firstErrorEvent = normalizeErrorEvent(event);
|
|
}
|
|
});
|
|
info("Sending args to worker: " + JSON.stringify(args));
|
|
worker.postMessage(args);
|
|
|
|
return promise;
|
|
}
|
|
|
|
function makeTestPermutations() {
|
|
for (const urlPerm of urlPermutations) {
|
|
for (const scriptPerm of scriptPermutations) {
|
|
for (const nestedPerm of nestedPermutations) {
|
|
const testName =
|
|
`${nestedPerm.name}: ${urlPerm.name}: ${scriptPerm.name}`;
|
|
const caseFunc = async () => {
|
|
// Make the test name much more obvious when viewing logs.
|
|
info(`#############################################################`);
|
|
info(`### ${testName}`);
|
|
let result, errorEvent;
|
|
|
|
const scriptName = urlPerm.transformScriptImport(scriptPerm.script);
|
|
const redirectedUrl = urlPerm.expectedURLAfterRedirect(scriptPerm.script);
|
|
|
|
// ### Same-Origin Import
|
|
// ## What does the error look like when caught?
|
|
({ message, bindingErrorEvent } = await asyncWorkerImport(
|
|
{
|
|
url: `${sameOriginBaseURL}/${scriptName}`,
|
|
mode: "catch",
|
|
nested: nestedPerm.nested,
|
|
}));
|
|
|
|
const sameOriginContext = {
|
|
workerUrl: workerAbsoluteUrl,
|
|
importUrl: message.args.url,
|
|
postRedirectUrl: `${sameOriginBaseURL}/${redirectedUrl}`,
|
|
isRedirected: message.args.url !== redirectedUrl,
|
|
shouldNotInclude: urlPerm.partOfTheURLToNotExposeToJS,
|
|
};
|
|
|
|
checkError(
|
|
`${testName}: Same-Origin Thrown`,
|
|
scriptPerm.sameOrigin,
|
|
sameOriginContext,
|
|
message.error);
|
|
|
|
// ## What does the error events look like when not caught?
|
|
({ message, bindingErrorEvent } = await asyncWorkerImport(
|
|
{
|
|
url: `${sameOriginBaseURL}/${scriptName}`,
|
|
mode: "uncaught",
|
|
nested: nestedPerm.nested,
|
|
}));
|
|
|
|
// The worker will have captured the error event twice, once via
|
|
// onerror and once via an "error" event listener. It will have not
|
|
// invoked preventDefault(), so the worker's parent will also have
|
|
// received a copy of the error event as well.
|
|
checkErrorEvent(
|
|
`${testName}: Same-Origin Worker global onerror handler`,
|
|
scriptPerm.sameOrigin,
|
|
sameOriginContext,
|
|
message.onerrorEvent);
|
|
checkErrorEvent(
|
|
`${testName}: Same-Origin Worker global error listener`,
|
|
scriptPerm.sameOrigin,
|
|
sameOriginContext,
|
|
message.listenerEvent);
|
|
// Binding events
|
|
checkErrorEvent(
|
|
`${testName}: Same-Origin Parent binding onerror`,
|
|
scriptPerm.sameOrigin,
|
|
sameOriginContext,
|
|
bindingErrorEvent, "via-task");
|
|
|
|
// ### Cross-Origin Import
|
|
// ## What does the error look like when caught?
|
|
({ message, bindingErrorEvent } = await asyncWorkerImport(
|
|
{
|
|
url: `${crossOriginBaseURL}/${scriptName}`,
|
|
mode: "catch",
|
|
nested: nestedPerm.nested,
|
|
}));
|
|
|
|
const crossOriginContext = {
|
|
workerUrl: workerAbsoluteUrl,
|
|
importUrl: message.args.url,
|
|
postRedirectUrl: `${crossOriginBaseURL}/${redirectedUrl}`,
|
|
isRedirected: message.args.url !== redirectedUrl,
|
|
shouldNotInclude: urlPerm.partOfTheURLToNotExposeToJS,
|
|
};
|
|
|
|
checkError(
|
|
`${testName}: Cross-Origin Thrown`,
|
|
scriptPerm.crossOrigin,
|
|
crossOriginContext,
|
|
message.error);
|
|
|
|
// ## What does the error events look like when not caught?
|
|
({ message, bindingErrorEvent } = await asyncWorkerImport(
|
|
{
|
|
url: `${crossOriginBaseURL}/${scriptName}`,
|
|
mode: "uncaught",
|
|
nested: nestedPerm.nested,
|
|
}));
|
|
|
|
// The worker will have captured the error event twice, once via
|
|
// onerror and once via an "error" event listener. It will have not
|
|
// invoked preventDefault(), so the worker's parent will also have
|
|
// received a copy of the error event as well.
|
|
checkErrorEvent(
|
|
`${testName}: Cross-Origin Worker global onerror handler`,
|
|
scriptPerm.crossOrigin,
|
|
crossOriginContext,
|
|
message.onerrorEvent);
|
|
checkErrorEvent(
|
|
`${testName}: Cross-Origin Worker global error listener`,
|
|
scriptPerm.crossOrigin,
|
|
crossOriginContext,
|
|
message.listenerEvent);
|
|
// Binding events
|
|
checkErrorEvent(
|
|
`${testName}: Cross-Origin Parent binding onerror`,
|
|
scriptPerm.crossOrigin,
|
|
crossOriginContext,
|
|
bindingErrorEvent, "via-task");
|
|
};
|
|
|
|
// The mochitest framework uses the name of the caseFunc, which by default
|
|
// will be inferred and set on the configurable `name` property. It's not
|
|
// writable though, so we need to clobber the property. Devtools will
|
|
// xray through this name but this works for the test framework.
|
|
Object.defineProperty(
|
|
caseFunc,
|
|
'name',
|
|
{
|
|
value: testName,
|
|
writable: false
|
|
});
|
|
add_task(caseFunc);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
makeTestPermutations();
|
|
</script>
|
|
</body>
|
|
</html>
|