tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

test_importScripts_3rdparty.html (21796B)


      1 <!--
      2  Any copyright is dedicated to the Public Domain.
      3  http://creativecommons.org/publicdomain/zero/1.0/
      4 -->
      5 <!DOCTYPE HTML>
      6 <html>
      7 <head>
      8  <title>Test for 3rd party imported script and muted errors</title>
      9  <script src="/tests/SimpleTest/SimpleTest.js"></script>
     10  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
     11 </head>
     12 <body>
     13 <script type="text/javascript">
     14 
     15 const workerURL = 'http://mochi.test:8888/tests/dom/workers/test/importScripts_3rdParty_worker.js';
     16 
     17 const sameOriginBaseURL = 'http://mochi.test:8888/tests/dom/workers/test';
     18 const crossOriginBaseURL = "https://example.com/tests/dom/workers/test";
     19 
     20 const workerRelativeUrl = 'importScripts_3rdParty_worker.js';
     21 const workerAbsoluteUrl = `${sameOriginBaseURL}/${workerRelativeUrl}`
     22 
     23 /**
     24 * This file tests cross-origin error muting in importScripts for workers.  In
     25 * particular, we want to test:
     26 * - The errors thrown by the parsing phase of importScripts().
     27 * - The errors thrown by the top-level evaluation phase of importScripts().
     28 * - If the error is reported to the parent's Worker binding, including through
     29 *   nested workers, as well as the contents of the error.
     30 * - For errors:
     31 *   - What type of exception is reported?
     32 *   - What fileName is reported on the exception?
     33 *   - What are the contents of the stack on the exception?
     34 *
     35 * Relevant specs:
     36 * - https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-worker-imported-script
     37 * - https://html.spec.whatwg.org/multipage/webappapis.html#creating-a-classic-script
     38 *
     39 * The situation and motivation for error muting is:
     40 * - JS scripts are allowed to be loaded cross-origin without CORS for legacy
     41 *   reasons.  If a script is cross-origin, its "muted errors" is set to true.
     42 *   - The fetch will set the "use-URL-credentials" flag
     43 *     https://fetch.spec.whatwg.org/#concept-request-use-url-credentials-flag
     44 *     but will have the default "credentials" mode of "omit"
     45 *     https://fetch.spec.whatwg.org/#concept-request-credentials-mode which
     46 *     means that username/password will be propagated.
     47 * - For legacy reasons, JS scripts aren't required to have an explicit JS MIME
     48 *   type which allows attacks that attempt to load a known-non JS file as JS
     49 *   in order to derive information from the errors or from side-effects to the
     50 *   global for code that does parse and evaluate as legal JS.
     51 */
     52 
     53 
     54 /**
     55  * - `sameOrigin`: Describes the exception we expect to see for a same-origin
     56  *   import.
     57  * - `crossOrigin`: Describes the exception we expect to see for a cross-origin
     58  *   import (from example.com while the worker is the mochitest origin).
     59  *
     60  * The exception fields are:
     61  * - `exceptionName`: The `name` of the Error object.
     62  * - `thrownFile`: Describes the filename we expect to see on the error:
     63  *   - `importing-worker-script`: The worker script that's doing the importing
     64  *     will be the source of the exception, not the imported script.
     65  *   - `imported-script-no-redirect`: The (absolute-ified) script as passed to
     66  *     importScript(s), regardless of any redirects that occur.
     67  *   - `post-redirect-imported-script`: The name of the actual URL that was
     68  *     loaded following any redirects.
     69  */
     70 const scriptPermutations = [
     71  {
     72    name: 'Invalid script that generates a syntax error',
     73    script: 'invalid.js',
     74    sameOrigin: {
     75      exceptionName: 'SyntaxError',
     76      thrownFile: 'post-redirect-imported-script',
     77      isDOMException: false,
     78      message: "expected expression, got end of script"
     79    },
     80    crossOrigin: {
     81      exceptionName: 'NetworkError',
     82      thrownFile: 'importing-worker-script',
     83      isDOMException: true,
     84      code: DOMException.NETWORK_ERR,
     85      message: "A network error occurred."
     86    }
     87  },
     88  {
     89    name: 'Non-JS MIME Type',
     90    script: 'mime_type_is_csv.js',
     91    sameOrigin: {
     92      exceptionName: 'NetworkError',
     93      thrownFile: 'importing-worker-script',
     94      isDOMException: true,
     95      code: DOMException.NETWORK_ERR,
     96      message: "A network error occurred."
     97    },
     98    crossOrigin: {
     99      exceptionName: 'NetworkError',
    100      thrownFile: 'importing-worker-script',
    101      isDOMException: true,
    102      code: DOMException.NETWORK_ERR,
    103      message: "A network error occurred."
    104    }
    105  },
    106  {
    107    // What happens if the script is a 404?
    108    name: 'Nonexistent script',
    109    script: 'script_does_not_exist.js',
    110    sameOrigin: {
    111      exceptionName: 'NetworkError',
    112      thrownFile: 'importing-worker-script',
    113      isDOMException: true,
    114      code: DOMException.NETWORK_ERR,
    115      message: "A network error occurred."
    116    },
    117    crossOrigin: {
    118      exceptionName: 'NetworkError',
    119      thrownFile: 'importing-worker-script',
    120      isDOMException: true,
    121      code: DOMException.NETWORK_ERR,
    122      message: "A network error occurred."
    123    }
    124  },
    125  {
    126    name: 'Script that throws during toplevel execution',
    127    script: 'toplevel_throws.js',
    128    sameOrigin: {
    129      exceptionName: 'Error',
    130      thrownFile: 'post-redirect-imported-script',
    131      isDOMException: false,
    132      message: "Toplevel-Throw-Payload",
    133    },
    134    crossOrigin: {
    135      exceptionName: 'NetworkError',
    136      thrownFile: 'importing-worker-script',
    137      isDOMException: true,
    138      code: DOMException.NETWORK_ERR,
    139      message: "A network error occurred."
    140    }
    141  },
    142  {
    143    name: 'Script that exposes a method that throws',
    144    script: 'call_throws.js',
    145    sameOrigin: {
    146      exceptionName: 'Error',
    147      thrownFile: 'post-redirect-imported-script',
    148      isDOMException: false,
    149      message: "Method-Throw-Payload"
    150    },
    151    crossOrigin: {
    152      exceptionName: 'Error',
    153      thrownFile: 'imported-script-no-redirect',
    154      isDOMException: false,
    155      message: "Method-Throw-Payload"
    156    }
    157  },
    158 ];
    159 
    160 /**
    161 * Special fields:
    162 * - `transformScriptImport`: A function that takes the script name as input and
    163 *   produces the actual path to use for import purposes, allowing the addition
    164 *   of a redirect.
    165 * - `expectedURLAfterRedirect`: A function that takes the script name as
    166 *   input and produces the expected script name post-redirect (if there is a
    167 *   redirect).  In particular, our `redirect_with_query_args.sjs` helper will
    168 *   perform a same-origin redirect and append "?SECRET_DATA" onto the end of
    169 *   the redirected URL at this time.
    170 * - `partOfTheURLToNotExposeToJS`: A string snippet that is present in the
    171 *   post-redirect contents that should absolutely not show up in the error's
    172 *   stack if the redirect isn't exposed.  This is a secondary check to the
    173 *   result of expectedURLAfterRedirect.
    174 */
    175 const urlPermutations = [
    176  {
    177    name: 'No Redirect',
    178    transformScriptImport: x => x,
    179    expectedURLAfterRedirect: x => x,
    180    // No redirect means nothing to be paranoid about.
    181    partOfTheURLToNotExposeToJS: null,
    182  },
    183  {
    184    name: 'Same-Origin Redirect With Query Args',
    185    // We mangle the script into uppercase and the redirector undoes this in
    186    // order to minimize the similarity of the pre-redirect and post-redirect
    187    // strings.
    188    transformScriptImport: x => `redirect_with_query_args.sjs?${x.toUpperCase()}`,
    189    expectedURLAfterRedirect: x => `${x}?SECRET_DATA`,
    190    // The redirect will add this when it formulates the redirected URL, and the
    191    // test wants to make sure this doesn't show up in filenames or stacks
    192    // unless the thrownFile is set to 'post-redirect-imported-script'.
    193    partOfTheURLToNotExposeToJS: 'SECRET_DATA',
    194  }
    195 ];
    196 const nestedPermutations = [
    197  {
    198    name: 'Window Parent',
    199    nested: false,
    200  },
    201  {
    202    name: 'Worker Parent',
    203    nested: true,
    204  }
    205 ];
    206 
    207 // NOTE: These implementations are copied from importScripts_3rdParty_worker.js
    208 // for reasons of minimizing the number of calls to importScripts for
    209 // debugging.
    210 function normalizeError(err) {
    211  if (!err) {
    212    return null;
    213  }
    214 
    215  const isDOMException = "filename" in err;
    216 
    217  return {
    218    message: err.message,
    219    name: err.name,
    220    isDOMException,
    221    code: err.code,
    222    // normalize to fileName
    223    fileName: isDOMException ? err.filename : err.fileName,
    224    hasFileName: !!err.fileName,
    225    hasFilename: !!err.filename,
    226    lineNumber: err.lineNumber,
    227    columnNumber: err.columnNumber,
    228    stack: err.stack,
    229    stringified: err.toString(),
    230  };
    231 }
    232 
    233 function normalizeErrorEvent(event) {
    234  if (!event) {
    235    return null;
    236  }
    237 
    238  return {
    239    message: event.message,
    240    filename: event.filename,
    241    lineno: event.lineno,
    242    colno: event.colno,
    243    error: normalizeError(event.error),
    244    stringified: event.toString(),
    245  };
    246 }
    247 // End duplicated code.
    248 
    249 
    250 /**
    251 * Validate the received error against our expectations and provided context.
    252 *
    253 * For `expectation`, see the `scriptPermutations` doc-block which documents
    254 * its `sameOrigin` and `crossOrigin` properties which are what we expect here.
    255 *
    256 * The `context` should include:
    257 * - `workerUrl`: The absolute URL of the toplevel worker script that the worker
    258 *   is running which is the code that calls `importScripts`.
    259 * - `importUrl`: The absolute URL provided to the call to `importScripts`.
    260 *   This is the pre-redirect URL if a redirect is involved.
    261 * - `postRedirectUrl`: The same as `importUrl` unless a redirect is involved,
    262 *   in which case this will be a different URL.
    263 * - `isRedirected`: Boolean indicating whether a redirect was involved.  This
    264 *   is a convenience variable that's derived from the above 2 URL's for now.
    265 * - `shouldNotInclude`: Provided by the URL permutation, this is used to check
    266 *   that post-redirect data does not creep into the exception unless the
    267 *   expected `thrownFile` is `post-redirect-imported-script`.
    268 */
    269 function checkError(label, expectation, context, err) {
    270  info(`## Checking error: ${JSON.stringify(err)}`);
    271  is(err.name, expectation.exceptionName,
    272     `${label}: Error name matches "${expectation.exceptionName}"?`);
    273  is(err.isDOMException, expectation.isDOMException,
    274     `${label}: Is a DOM Exception == ${expectation.isDOMException}?`);
    275  if (expectation.code) {
    276    is(err.code, expectation.code,
    277       `${label}: Code matches ${expectation.code}?`);
    278  }
    279 
    280  let expectedFile;
    281  switch (expectation.thrownFile) {
    282    case 'importing-worker-script':
    283      expectedFile = context.workerUrl;
    284      break;
    285    case 'imported-script-no-redirect':
    286      expectedFile = context.importUrl;
    287      break;
    288    case 'post-redirect-imported-script':
    289      expectedFile = context.postRedirectUrl;
    290      break;
    291    default:
    292      ok(false, `Unexpected thrownFile parameter: ${expectation.thrownFile}`);
    293      return;
    294  }
    295 
    296  is(err.fileName, expectedFile,
    297     `${label}: Filename from ${expectation.thrownFile} is ${expectedFile}`);
    298 
    299 
    300  let expMessage = expectation.message;
    301  if (typeof(expMessage) === "function") {
    302    expMessage = expectation.message(context);
    303  }
    304  is(err.message, expMessage,
    305      `${label}: Message is ${expMessage}`);
    306 
    307  // If this is a redirect and we expect the error to not be surfacing any
    308  // post-redirect information and there's a `shouldNotInclude` string, then
    309  // check to make sure it's not present.
    310  if (context.isRedirected && context.shouldNotInclude) {
    311    if (expectation.thrownFile !== 'post-redirect-imported-script') {
    312      ok(!err.stack.includes(context.shouldNotInclude),
    313        `${label}: Stack should not include ${context.shouldNotInclude}:\n${err.stack}`);
    314      ok(!err.stringified.includes(context.shouldNotInclude),
    315        `${label}: Stringified error should not include ${context.shouldNotInclude}:\n${err.stringified}`);
    316    } else if (expectation.exceptionName !== 'SyntaxError') {
    317      // We do expect the shouldNotInclude to be present for
    318      // 'post-redirect-imported-script' as long as the exception isn't a
    319      // SyntaxError.  SyntaxError stacks inherently do not include the filename
    320      // of the file with the syntax problem as a stack frame.
    321      ok(err.stack.includes(context.shouldNotInclude),
    322         `${label}: Stack should include ${context.shouldNotInclude}:\n${err.stack}`);
    323    }
    324  }
    325  let expStringified = `${err.name}: ${expMessage}`;
    326  is(err.stringified, expStringified,
    327    `${label}: Stringified error should be: ${expStringified}`);
    328 
    329  // Add some whitespace in our output.
    330  info("");
    331 }
    332 
    333 function checkErrorEvent(label, expectation, context, event, viaTask=false) {
    334  info(`## Checking error event: ${JSON.stringify(event)}`);
    335 
    336  let expectedFile;
    337  switch (expectation.thrownFile) {
    338    case 'importing-worker-script':
    339      expectedFile = context.workerUrl;
    340      break;
    341    case 'imported-script-no-redirect':
    342      expectedFile = context.importUrl;
    343      break;
    344    case 'post-redirect-imported-script':
    345      expectedFile = context.postRedirectUrl;
    346      break;
    347    default:
    348      ok(false, `Unexpected thrownFile parameter: ${expectation.thrownFile}`);
    349      return;
    350  }
    351 
    352  is(event.filename, expectedFile,
    353     `${label}: Filename from ${expectation.thrownFile} is ${expectedFile}`);
    354 
    355  let expMessage = expectation.message;
    356  if (typeof(expMessage) === "function") {
    357    expMessage = expectation.message(context);
    358  }
    359  // The error event message prepends the exception name to the Error's message.
    360  expMessage = `${expectation.exceptionName}: ${expMessage}`;
    361 
    362  is(event.message, expMessage,
    363      `${label}: Message is ${expMessage}`);
    364 
    365  // If this is a redirect and we expect the error to not be surfacing any
    366  // post-redirect information and there's a `shouldNotInclude` string, then
    367  // check to make sure it's not present.
    368  //
    369  // Note that `stringified` may not be present for the "onerror" case.
    370  if (context.isRedirected &&
    371      expectation.thrownFile !== 'post-redirect-imported-script' &&
    372      context.shouldNotInclude &&
    373      event.stringified) {
    374    ok(!event.stringified.includes(context.shouldNotInclude),
    375       `${label}: Stringified error should not include ${context.shouldNotInclude}:\n${event.stringified}`);
    376  }
    377  if (event.stringified) {
    378    is(event.stringified, "[object ErrorEvent]",
    379      `${label}: Stringified event should be "[object ErrorEvent]"`);
    380  }
    381 
    382  // If we received the error via a task queued because it was not handled in
    383  // the worker, then per
    384  // https://html.spec.whatwg.org/multipage/workers.html#runtime-script-errors-2
    385  // the error will be null.
    386  if (viaTask) {
    387    is(event.error, null,
    388      `${label}: Error is null because it came from an HTML 10.2.5 task.`);
    389  } else {
    390    checkError(label, expectation, context, event.error);
    391  }
    392 }
    393 
    394 /**
    395 * Helper to spawn a worker, postMessage it the given args, and return the
    396 * worker's response payload and the first "error" received on the Worker
    397 * binding by the time the message handler resolves.  The worker logic makes
    398 * sure to delay its postMessage using setTimeout(0) so error events will always
    399 * arrive before any message that is sent.
    400 *
    401 * If args includes a truthy `nested` value, then the `message` and
    402 * `bindingErrorEvent` are as perceived by the parent worker.
    403 */
    404 function asyncWorkerImport(args) {
    405  const worker = new Worker(workerRelativeUrl);
    406  const promise = new Promise((resolve, reject) => {
    407    // The first "error" received on the Worker binding.
    408    let firstErrorEvent = null;
    409 
    410    worker.onmessage = function(event) {
    411      let message = event.data;
    412      // For the nested case, unwrap and normalize things.
    413      if (args.nested) {
    414        firstErrorEvent = message.errorEvent;
    415        message = message.nestedMessage;
    416        // We need to re-set the argument to be nested because it was set to
    417        // false so that only a single level of nesting occurred.
    418        message.args.nested = true;
    419      }
    420 
    421      // Make sure the args we receive from the worker are the same as the ones
    422      // we sent.
    423      is(JSON.stringify(message.args), JSON.stringify(args),
    424         "Worker re-transmitted args match sent args.");
    425 
    426      resolve({
    427        message,
    428        bindingErrorEvent: firstErrorEvent
    429      });
    430      worker.terminate();
    431    };
    432    worker.onerror = function(event) {
    433      // We don't want this to bubble to the window and cause a test failure.
    434      event.preventDefault();
    435 
    436      if (firstErrorEvent) {
    437        ok(false, "Worker binding received more than one error");
    438        reject(new Error("multiple error events received"));
    439        return;
    440      }
    441      firstErrorEvent = normalizeErrorEvent(event);
    442    }
    443  });
    444  info("Sending args to worker: " + JSON.stringify(args));
    445  worker.postMessage(args);
    446 
    447  return promise;
    448 }
    449 
    450 function makeTestPermutations() {
    451  for (const urlPerm of urlPermutations) {
    452    for (const scriptPerm of scriptPermutations) {
    453      for (const nestedPerm of nestedPermutations) {
    454        const testName =
    455          `${nestedPerm.name}: ${urlPerm.name}: ${scriptPerm.name}`;
    456        const caseFunc = async () => {
    457          // Make the test name much more obvious when viewing logs.
    458          info(`#############################################################`);
    459          info(`### ${testName}`);
    460          let result, errorEvent;
    461 
    462          const scriptName = urlPerm.transformScriptImport(scriptPerm.script);
    463          const redirectedUrl = urlPerm.expectedURLAfterRedirect(scriptPerm.script);
    464 
    465          // ### Same-Origin Import
    466          // ## What does the error look like when caught?
    467          ({ message, bindingErrorEvent } = await asyncWorkerImport(
    468            {
    469              url: `${sameOriginBaseURL}/${scriptName}`,
    470              mode: "catch",
    471              nested: nestedPerm.nested,
    472            }));
    473 
    474          const sameOriginContext = {
    475            workerUrl: workerAbsoluteUrl,
    476            importUrl: message.args.url,
    477            postRedirectUrl: `${sameOriginBaseURL}/${redirectedUrl}`,
    478            isRedirected: message.args.url !== redirectedUrl,
    479            shouldNotInclude: urlPerm.partOfTheURLToNotExposeToJS,
    480          };
    481 
    482          checkError(
    483            `${testName}: Same-Origin Thrown`,
    484            scriptPerm.sameOrigin,
    485            sameOriginContext,
    486            message.error);
    487 
    488          // ## What does the error events look like when not caught?
    489          ({ message, bindingErrorEvent } = await asyncWorkerImport(
    490            {
    491              url: `${sameOriginBaseURL}/${scriptName}`,
    492              mode: "uncaught",
    493              nested: nestedPerm.nested,
    494            }));
    495 
    496          // The worker will have captured the error event twice, once via
    497          // onerror and once via an "error" event listener.  It will have not
    498          // invoked preventDefault(), so the worker's parent will also have
    499          // received a copy of the error event as well.
    500          checkErrorEvent(
    501            `${testName}: Same-Origin Worker global onerror handler`,
    502            scriptPerm.sameOrigin,
    503            sameOriginContext,
    504            message.onerrorEvent);
    505          checkErrorEvent(
    506            `${testName}: Same-Origin Worker global error listener`,
    507            scriptPerm.sameOrigin,
    508            sameOriginContext,
    509            message.listenerEvent);
    510          // Binding events
    511          checkErrorEvent(
    512            `${testName}: Same-Origin Parent binding onerror`,
    513            scriptPerm.sameOrigin,
    514            sameOriginContext,
    515            bindingErrorEvent, "via-task");
    516 
    517          // ### Cross-Origin Import
    518          // ## What does the error look like when caught?
    519          ({ message, bindingErrorEvent } = await asyncWorkerImport(
    520            {
    521              url: `${crossOriginBaseURL}/${scriptName}`,
    522              mode: "catch",
    523              nested: nestedPerm.nested,
    524            }));
    525 
    526          const crossOriginContext = {
    527            workerUrl: workerAbsoluteUrl,
    528            importUrl: message.args.url,
    529            postRedirectUrl: `${crossOriginBaseURL}/${redirectedUrl}`,
    530            isRedirected: message.args.url !== redirectedUrl,
    531            shouldNotInclude: urlPerm.partOfTheURLToNotExposeToJS,
    532          };
    533 
    534          checkError(
    535            `${testName}: Cross-Origin Thrown`,
    536            scriptPerm.crossOrigin,
    537            crossOriginContext,
    538            message.error);
    539 
    540          // ## What does the error events look like when not caught?
    541          ({ message, bindingErrorEvent } = await asyncWorkerImport(
    542            {
    543              url: `${crossOriginBaseURL}/${scriptName}`,
    544              mode: "uncaught",
    545              nested: nestedPerm.nested,
    546            }));
    547 
    548          // The worker will have captured the error event twice, once via
    549          // onerror and once via an "error" event listener.  It will have not
    550          // invoked preventDefault(), so the worker's parent will also have
    551          // received a copy of the error event as well.
    552          checkErrorEvent(
    553            `${testName}: Cross-Origin Worker global onerror handler`,
    554            scriptPerm.crossOrigin,
    555            crossOriginContext,
    556            message.onerrorEvent);
    557          checkErrorEvent(
    558            `${testName}: Cross-Origin Worker global error listener`,
    559            scriptPerm.crossOrigin,
    560            crossOriginContext,
    561            message.listenerEvent);
    562          // Binding events
    563          checkErrorEvent(
    564            `${testName}: Cross-Origin Parent binding onerror`,
    565            scriptPerm.crossOrigin,
    566            crossOriginContext,
    567            bindingErrorEvent, "via-task");
    568        };
    569 
    570        // The mochitest framework uses the name of the caseFunc, which by default
    571        // will be inferred and set on the configurable `name` property.  It's not
    572        // writable though, so we need to clobber the property.  Devtools will
    573        // xray through this name but this works for the test framework.
    574        Object.defineProperty(
    575          caseFunc,
    576          'name',
    577          {
    578            value: testName,
    579            writable: false
    580          });
    581        add_task(caseFunc);
    582      }
    583    }
    584  }
    585 }
    586 makeTestPermutations();
    587 </script>
    588 </body>
    589 </html>