tor-browser

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

fetch-later-helper.js (16217B)


      1 /**
      2 * IMPORTANT: Before using this file, you must also import the following files:
      3 * - /common/utils.js
      4 */
      5 'use strict';
      6 
      7 const ROOT_NAME = 'fetch/fetch-later';
      8 
      9 function parallelPromiseTest(func, description) {
     10  async_test((t) => {
     11    Promise.resolve(func(t)).then(() => t.done()).catch(t.step_func((e) => {
     12      throw e;
     13    }));
     14  }, description);
     15 }
     16 
     17 /** @enum {string} */
     18 const BeaconDataType = {
     19  String: 'String',
     20  ArrayBuffer: 'ArrayBuffer',
     21  FormData: 'FormData',
     22  URLSearchParams: 'URLSearchParams',
     23  Blob: 'Blob',
     24  File: 'File',
     25 };
     26 
     27 /** @enum {string} */
     28 const BeaconDataTypeToSkipCharset = {
     29  String: '',
     30  ArrayBuffer: '',
     31  FormData: '\n\r',  // CRLF characters will be normalized by FormData
     32  URLSearchParams: ';,/?:@&=+$',  // reserved URI characters
     33  Blob: '',
     34  File: '',
     35 };
     36 
     37 const BEACON_PAYLOAD_KEY = 'payload';
     38 
     39 // Creates beacon data of the given `dataType` from `data`.
     40 // @param {string} data - A string representation of the beacon data. Note that
     41 //     it cannot contain UTF-16 surrogates for all `BeaconDataType` except BLOB.
     42 // @param {BeaconDataType} dataType - must be one of `BeaconDataType`.
     43 // @param {string} contentType - Request Content-Type.
     44 function makeBeaconData(data, dataType, contentType) {
     45  switch (dataType) {
     46    case BeaconDataType.String:
     47      return data;
     48    case BeaconDataType.ArrayBuffer:
     49      return new TextEncoder().encode(data).buffer;
     50    case BeaconDataType.FormData:
     51      const formData = new FormData();
     52      if (data.length > 0) {
     53        formData.append(BEACON_PAYLOAD_KEY, data);
     54      }
     55      return formData;
     56    case BeaconDataType.URLSearchParams:
     57      if (data.length > 0) {
     58        return new URLSearchParams(`${BEACON_PAYLOAD_KEY}=${data}`);
     59      }
     60      return new URLSearchParams();
     61    case BeaconDataType.Blob: {
     62      const options = {type: contentType || undefined};
     63      return new Blob([data], options);
     64    }
     65    case BeaconDataType.File: {
     66      const options = {type: contentType || 'text/plain'};
     67      return new File([data], 'file.txt', options);
     68    }
     69    default:
     70      throw Error(`Unsupported beacon dataType: ${dataType}`);
     71  }
     72 }
     73 
     74 // Create a string of `end`-`begin` characters, with characters starting from
     75 // UTF-16 code unit `begin` to `end`-1.
     76 function generateSequentialData(begin, end, skip) {
     77  const codeUnits = Array(end - begin).fill().map((el, i) => i + begin);
     78  if (skip) {
     79    return String.fromCharCode(
     80        ...codeUnits.filter(c => !skip.includes(String.fromCharCode(c))));
     81  }
     82  return String.fromCharCode(...codeUnits);
     83 }
     84 
     85 function generatePayload(size) {
     86  if (size == 0) {
     87    return '';
     88  }
     89  const prefix = String(size) + ':';
     90  if (size < prefix.length) {
     91    return Array(size).fill('*').join('');
     92  }
     93  if (size == prefix.length) {
     94    return prefix;
     95  }
     96 
     97  return prefix + Array(size - prefix.length).fill('*').join('');
     98 }
     99 
    100 function generateSetBeaconURL(uuid, options) {
    101  const host = (options && options.host) || '';
    102  let url = `${host}/${ROOT_NAME}/resources/set_beacon.py?uuid=${uuid}`;
    103  if (options) {
    104    if (options.expectOrigin !== undefined) {
    105      url = `${url}&expectOrigin=${options.expectOrigin}`;
    106    }
    107    if (options.expectPreflight !== undefined) {
    108      url = `${url}&expectPreflight=${options.expectPreflight}`;
    109    }
    110    if (options.expectCredentials !== undefined) {
    111      url = `${url}&expectCredentials=${options.expectCredentials}`;
    112    }
    113 
    114    if (options.useRedirectHandler) {
    115      const redirect = `${host}/common/redirect.py` +
    116          `?location=${encodeURIComponent(url)}`;
    117      url = redirect;
    118    }
    119  }
    120  return url;
    121 }
    122 
    123 async function poll(asyncFunc, expected) {
    124  const maxRetries = 30;
    125  const waitInterval = 100;  // milliseconds.
    126  const delay = ms => new Promise(res => setTimeout(res, ms));
    127 
    128  let result = {data: []};
    129  for (let i = 0; i < maxRetries; i++) {
    130    result = await asyncFunc();
    131    if (!expected(result)) {
    132      await delay(waitInterval);
    133      continue;
    134    }
    135    return result;
    136  }
    137  return result;
    138 }
    139 
    140 // Waits until the `options.count` number of beacon data available from the
    141 // server. Defaults to 1.
    142 // If `options.data` is set, it will be used to compare with the data from the
    143 // response.
    144 async function expectBeacon(uuid, options) {
    145  const expectedCount =
    146      (options && options.count !== undefined) ? options.count : 1;
    147 
    148  const res = await poll(
    149      async () => {
    150        const res = await fetch(
    151            `/${ROOT_NAME}/resources/get_beacon.py?uuid=${uuid}`,
    152            {cache: 'no-store'});
    153        return await res.json();
    154      },
    155      (res) => {
    156        if (expectedCount == 0) {
    157          // If expecting no beacon, we should try to wait as long as possible.
    158          // So always returning false here until `poll()` decides to terminate
    159          // itself.
    160          return false;
    161        }
    162        return res.data.length == expectedCount;
    163      });
    164  if (!options || !options.data) {
    165    assert_equals(
    166        res.data.length, expectedCount,
    167        'Number of sent beacons does not match expected count:');
    168    return;
    169  }
    170 
    171  if (expectedCount == 0) {
    172    assert_equals(
    173        res.data.length, 0,
    174        'Number of sent beacons does not match expected count:');
    175    return;
    176  }
    177 
    178  const decoder = options && options.percentDecoded ? (s) => {
    179    // application/x-www-form-urlencoded serializer encodes space as '+'
    180    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
    181    s = s.replace(/\+/g, '%20');
    182    return decodeURIComponent(s);
    183  } : (s) => s;
    184 
    185  assert_equals(
    186      res.data.length, options.data.length,
    187      `The size of beacon data ${
    188          res.data.length} from server does not match expected value ${
    189          options.data.length}.`);
    190  for (let i = 0; i < options.data.length; i++) {
    191    assert_equals(
    192        decoder(res.data[i]), options.data[i],
    193        'The beacon data does not match expected value.');
    194  }
    195 }
    196 
    197 function generateHTML(script) {
    198  return `<!DOCTYPE html><body><script>${script}</script></body>`;
    199 }
    200 
    201 // Loads `script` into an iframe and appends it to the current document.
    202 // Returns the loaded iframe element.
    203 async function loadScriptAsIframe(script) {
    204  const iframe = document.createElement('iframe');
    205  iframe.srcdoc = generateHTML(script);
    206  const iframeLoaded = new Promise(resolve => iframe.onload = resolve);
    207  document.body.appendChild(iframe);
    208  await iframeLoaded;
    209  return iframe;
    210 }
    211 
    212 /**
    213 * A helper to make a fetchLater request and wait for it being received.
    214 *
    215 * This function can also be used when the caller does not care about where a
    216 * fetchLater() makes request to.
    217 *
    218 * @param {!RequestInit} init The request config to pass into fetchLater() call.
    219 */
    220 async function expectFetchLater(
    221    init, {targetUrl = undefined, uuid = undefined} = {}) {
    222  if ((targetUrl && !uuid) || (!targetUrl && uuid)) {
    223    throw new Error('uuid and targetUrl must be provided together.');
    224  }
    225  if (uuid && targetUrl && !targetUrl.includes(uuid)) {
    226    throw new Error(`Conflicting uuid=${
    227        uuid} is provided: must also be included in the targetUrl ${
    228        targetUrl}`);
    229  }
    230  if (!uuid) {
    231    uuid = token();
    232  }
    233  if (!targetUrl) {
    234    targetUrl = generateSetBeaconURL(uuid);
    235  }
    236 
    237  fetchLater(targetUrl, init);
    238 
    239  await expectBeacon(uuid, {count: 1});
    240 }
    241 
    242 /**
    243 * A helper to append `el` into document and wait for it being loaded.
    244 * @param {!Element} el
    245 */
    246 async function loadElement(el) {
    247  const loaded = new Promise(resolve => el.onload = resolve);
    248  document.body.appendChild(el);
    249  await loaded;
    250 }
    251 
    252 /**
    253 * The options to configure a fetchLater() call in an iframe.
    254 * @record
    255 */
    256 class FetchLaterIframeOptions {
    257  constructor() {
    258    /**
    259     * @type {string=} The url to pass to the fetchLater() call.
    260     */
    261    this.targetUrl;
    262 
    263    /**
    264     * @type {string=} The uuid to wait for. Must also be part of `targetUrl`.
    265     */
    266    this.uuid;
    267 
    268    /**
    269     * @type {number=} The activateAfter field of DeferredRequestInit to pass
    270     * to the fetchLater() call.
    271     * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit
    272     */
    273    this.activateAfter;
    274 
    275    /**
    276     * @type {string=} The method field of DeferredRequestInit to pass to the
    277     * fetchLater() call.
    278     * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit
    279     */
    280    this.method;
    281 
    282    /**
    283     * @type {string=} The referrer field of DeferredRequestInit to pass to the
    284     * fetchLater() call.
    285     * https://whatpr.org/fetch/1647.html#requestinit
    286     */
    287    this.referrer;
    288 
    289    /**
    290     * @type {string=} One of the `BeaconDataType` to tell the iframe how to
    291     * generate the body for its fetchLater() call.
    292     */
    293    this.bodyType;
    294 
    295    /**
    296     * @type {number=} The size to tell the iframe how to generate the body of
    297     * its fetchLater() call.
    298     */
    299    this.bodySize;
    300 
    301    /**
    302     * @type {bool} Whether to set allow="deferred-fetch" attribute for the
    303     * iframe. Combing with a Permissions-Policy header, this will enable
    304     * fetchLater() being used in a cross-origin iframe.
    305     */
    306    this.allowDeferredFetch;
    307 
    308    /**
    309     * @type {string=} The sandbox attribute to apply to the iframe.
    310     */
    311    this.sandbox;
    312 
    313    /**
    314     * @type {FetchLaterIframeExpectation=} The expectation on the iframe's
    315     * behavior.
    316     */
    317    this.expect;
    318  }
    319 }
    320 
    321 /**
    322 * The enum to classify the messages posted from an iframe that has called
    323 * fetchLater() API.
    324 * @enum {string}
    325 */
    326 const FetchLaterIframeMessageType = {
    327  // Tells that a fetchLater() call has been executed without any error thrown.
    328  DONE: 'fetchLater.done',
    329  // Tells that there are some error thrown from a fetchLater() call.
    330  ERROR: 'fetchLater.error',
    331 };
    332 
    333 /**
    334 * The enum to indicate what type of iframe behavior the caller is expecting.
    335 * @enum {number}
    336 */
    337 const FetchLaterExpectationType = {
    338  // A fetchLater() call should have been made without any errors.
    339  DONE: 0,
    340  // A fetchLater() call is made and an JS error is thrown.
    341  ERROR_JS: 1,
    342  // A fetchLater() call is made and an DOMException is thrown.
    343  ERROR_DOM: 2,
    344 };
    345 
    346 class FetchLaterExpectationError extends Error {
    347  constructor(src, actual, expected) {
    348    const message = `iframe[src=${src}] threw ${actual}, expected ${expected}`;
    349    super(message);
    350  }
    351 }
    352 
    353 class FetchLaterIframeExpectation {
    354  constructor(expectationType, expectedError) {
    355    this.expectationType = expectationType;
    356    if (expectationType == FetchLaterExpectationType.DONE && !expectedError) {
    357      this.expectedErrorType = undefined;
    358    } else if (
    359        expectationType == FetchLaterExpectationType.ERROR_JS &&
    360        typeof expectedError == 'function') {
    361      this.expectedErrorType = expectedError;
    362    } else if (
    363        expectationType == FetchLaterExpectationType.ERROR_DOM &&
    364        typeof expectedError == 'string') {
    365      this.expectedDomErrorName = expectedError;
    366    } else {
    367      throw Error(`Expectation type "${expectationType}" and expected error "${
    368          expectedError}" do not match`);
    369    }
    370  }
    371 
    372  /**
    373   * Verifies the message from `e` against the configured expectation.
    374   *
    375   * @param {MessageEvent} e
    376   * @param {string} url The source URL of the iframe where `e` is dispatched
    377   * from.
    378   * @return {bool}
    379   * - Returns true if the expected message event is passed into the function
    380   *   and the expectation is fulfilled. The caller should be able to safely
    381   *   remove the message event listener afterwards.
    382   * - Returns false if the passed in event is not of the expected type. The
    383   *   caller should continue waiting for another message event and call this
    384   *   function again.
    385   * @throws {Error} Throws an error if the expected message event is passed but
    386   *   the expectation fails. The caller should remove the message event
    387   *   listener and perform test failure handling.
    388   */
    389  run(e, url) {
    390    if (this.expectationType === FetchLaterExpectationType.DONE) {
    391      if (e.data.type === FetchLaterIframeMessageType.DONE) {
    392        return true;
    393      }
    394      if (e.data.type === FetchLaterIframeMessageType.ERROR &&
    395          e.data.error !== undefined) {
    396        throw new FetchLaterExpectationError(
    397            url, e.data.error.name, 'no error');
    398      }
    399    }
    400 
    401    if (this.expectationType === FetchLaterExpectationType.ERROR_JS) {
    402      if (e.data.type === FetchLaterIframeMessageType.DONE) {
    403        throw new FetchLaterExpectationError(
    404            url, 'nothing', this.expectedErrorType.name);
    405      }
    406      if (e.data.type === FetchLaterIframeMessageType.ERROR) {
    407        if (e.data.error.name === this.expectedErrorType.name) {
    408          return true;
    409        }
    410        throw new FetchLaterExpectationError(
    411            url, e.data.error, this.expectedErrorType.name);
    412      }
    413    }
    414 
    415    if (this.expectationType === FetchLaterExpectationType.ERROR_DOM) {
    416      if (e.data.type === FetchLaterIframeMessageType.DONE) {
    417        throw new FetchLaterExpectationError(
    418            url, 'nothing', this.expectedDomErrorName);
    419      }
    420      if (e.data.type === FetchLaterIframeMessageType.ERROR) {
    421        const actual = e.data.error.name || e.data.error.type;
    422        if (this.expectedDomErrorName === 'QuotaExceededError') {
    423          return actual == this.expectedDomErrorName;
    424        } else if (actual == this.expectedDomErrorName) {
    425          return true;
    426        }
    427        throw new FetchLaterExpectationError(
    428            url, actual, this.expectedDomErrorName);
    429      }
    430    }
    431 
    432    return false;
    433  }
    434 }
    435 
    436 /**
    437 * A helper to load an iframe of the specified `origin` that makes a fetchLater
    438 * request to `targetUrl`.
    439 *
    440 * If `targetUrl` is not provided, this function generates a target URL by
    441 * itself.
    442 *
    443 * If `expect` is not provided:
    444 * - If `targetUrl` is not provided, this function will wait for the fetchLater
    445 *   request being received by the test server before returning.
    446 * - If `targetUrl` is provided and `uuid` is missing, it will NOT wait for the
    447 *   request.
    448 * - If both `targetUrl` and `uuid` are provided, it will wait for the request.
    449 *
    450 * Note that the iframe posts various messages back to its parent document.
    451 *
    452 * @param {!string} origin The origin URL of the iframe to load.
    453 * @param {FetchLaterIframeOptions=} nameIgnored
    454 * @return {!HTMLIFrameElement} the loaded iframe.
    455 */
    456 async function loadFetchLaterIframe(origin, {
    457  targetUrl = undefined,
    458  uuid = undefined,
    459  activateAfter = undefined,
    460  referrer = undefined,
    461  method = undefined,
    462  bodyType = undefined,
    463  bodySize = undefined,
    464  allowDeferredFetch = false,
    465  sandbox = undefined,
    466  expect = undefined
    467 } = {}) {
    468  if (uuid && targetUrl && !targetUrl.includes(uuid)) {
    469    throw new Error(`Conflicted uuid=${
    470        uuid} is provided: must also be included in the targetUrl ${
    471        targetUrl}`);
    472  }
    473  if (!uuid) {
    474    uuid = targetUrl ? undefined : token();
    475  }
    476  targetUrl = targetUrl || generateSetBeaconURL(uuid);
    477  const params = new URLSearchParams(Object.assign(
    478      {},
    479      {url: encodeURIComponent(targetUrl)},
    480      activateAfter !== undefined ? {activateAfter} : null,
    481      referrer !== undefined ? {referrer} : null,
    482      method !== undefined ? {method} : null,
    483      bodyType !== undefined ? {bodyType} : null,
    484      bodySize !== undefined ? {bodySize} : null,
    485      ));
    486  const url =
    487      `${origin}/fetch/fetch-later/resources/fetch-later.html?${params}`;
    488  expect =
    489      expect || new FetchLaterIframeExpectation(FetchLaterExpectationType.DONE);
    490 
    491  const iframe = document.createElement('iframe');
    492  if (allowDeferredFetch) {
    493    iframe.allow = 'deferred-fetch';
    494  }
    495  if (sandbox) {
    496    iframe.sandbox = sandbox;
    497  }
    498  iframe.src = url;
    499 
    500  const messageReceived = new Promise((resolve, reject) => {
    501    addEventListener('message', function handler(e) {
    502      if (e.source !== iframe.contentWindow) {
    503        return;
    504      }
    505      try {
    506        if (expect.run(e, url)) {
    507          removeEventListener('message', handler);
    508          resolve(e.data.type);
    509        }
    510      } catch (err) {
    511        reject(err);
    512      }
    513    });
    514  });
    515 
    516  await loadElement(iframe);
    517  const messageType = await messageReceived;
    518  if (messageType === FetchLaterIframeMessageType.DONE && uuid) {
    519    await expectBeacon(uuid, {count: 1});
    520  }
    521 
    522  return iframe;
    523 }