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>