tor-browser

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

rest.sys.mjs (19367B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
      6 
      7 import { Log } from "resource://gre/modules/Log.sys.mjs";
      8 
      9 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
     10 
     11 const lazy = {};
     12 
     13 ChromeUtils.defineESModuleGetters(lazy, {
     14  CryptoUtils: "moz-src:///services/crypto/modules/utils.sys.mjs",
     15 });
     16 
     17 function decodeString(data, charset) {
     18  if (!data || !charset) {
     19    return data;
     20  }
     21 
     22  // This could be simpler if we assumed the charset is only ever UTF-8.
     23  // It's unclear to me how willing we are to assume this, though...
     24  let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
     25    Ci.nsIStringInputStream
     26  );
     27  stringStream.setByteStringData(data);
     28 
     29  let converterStream = Cc[
     30    "@mozilla.org/intl/converter-input-stream;1"
     31  ].createInstance(Ci.nsIConverterInputStream);
     32 
     33  converterStream.init(
     34    stringStream,
     35    charset,
     36    0,
     37    converterStream.DEFAULT_REPLACEMENT_CHARACTER
     38  );
     39 
     40  let remaining = data.length;
     41  let body = "";
     42  while (remaining > 0) {
     43    let str = {};
     44    let num = converterStream.readString(remaining, str);
     45    if (!num) {
     46      break;
     47    }
     48    remaining -= num;
     49    body += str.value;
     50  }
     51  return body;
     52 }
     53 
     54 /**
     55 * Single use HTTP requests to RESTish resources.
     56 *
     57 * @param uri
     58 *        URI for the request. This can be an nsIURI object or a string
     59 *        that can be used to create one. An exception will be thrown if
     60 *        the string is not a valid URI.
     61 *
     62 * Examples:
     63 *
     64 * (1) Quick GET request:
     65 *
     66 *   let response = await new RESTRequest("http://server/rest/resource").get();
     67 *   if (!response.success) {
     68 *     // Bail out if we're not getting an HTTP 2xx code.
     69 *     processHTTPError(response.status);
     70 *     return;
     71 *   }
     72 *   processData(response.body);
     73 *
     74 * (2) Quick PUT request (non-string data is automatically JSONified)
     75 *
     76 *   let response = await new RESTRequest("http://server/rest/resource").put(data);
     77 */
     78 export function RESTRequest(uri) {
     79  this.status = this.NOT_SENT;
     80 
     81  // If we don't have an nsIURI object yet, make one. This will throw if
     82  // 'uri' isn't a valid URI string.
     83  if (!(uri instanceof Ci.nsIURI)) {
     84    uri = Services.io.newURI(uri);
     85  }
     86  this.uri = uri;
     87 
     88  this._headers = {};
     89  this._deferred = Promise.withResolvers();
     90  this._log = Log.repository.getLogger(this._logName);
     91  this._log.manageLevelFromPref("services.common.log.logger.rest.request");
     92 }
     93 
     94 RESTRequest.prototype = {
     95  _logName: "Services.Common.RESTRequest",
     96 
     97  QueryInterface: ChromeUtils.generateQI([
     98    "nsIInterfaceRequestor",
     99    "nsIChannelEventSink",
    100  ]),
    101 
    102  /** Public API: */
    103 
    104  /**
    105   * URI for the request (an nsIURI object).
    106   */
    107  uri: null,
    108 
    109  /**
    110   * HTTP method (e.g. "GET")
    111   */
    112  method: null,
    113 
    114  /**
    115   * RESTResponse object
    116   */
    117  response: null,
    118 
    119  /**
    120   * nsIRequest load flags. Don't do any caching by default. Don't send user
    121   * cookies and such over the wire (Bug 644734).
    122   */
    123  loadFlags:
    124    Ci.nsIRequest.LOAD_BYPASS_CACHE |
    125    Ci.nsIRequest.INHIBIT_CACHING |
    126    Ci.nsIRequest.LOAD_ANONYMOUS,
    127 
    128  /**
    129   * nsIHttpChannel
    130   */
    131  channel: null,
    132 
    133  /**
    134   * Flag to indicate the status of the request.
    135   *
    136   * One of NOT_SENT, SENT, IN_PROGRESS, COMPLETED, ABORTED.
    137   */
    138  status: null,
    139 
    140  NOT_SENT: 0,
    141  SENT: 1,
    142  IN_PROGRESS: 2,
    143  COMPLETED: 4,
    144  ABORTED: 8,
    145 
    146  /**
    147   * HTTP status text of response
    148   */
    149  statusText: null,
    150 
    151  /**
    152   * Request timeout (in seconds, though decimal values can be used for
    153   * up to millisecond granularity.)
    154   *
    155   * 0 for no timeout. Default is 300 seconds (5 minutes), the same as Sync uses
    156   * in resource.js.
    157   */
    158  timeout: 300,
    159 
    160  /**
    161   * The encoding with which the response to this request must be treated.
    162   * If a charset parameter is available in the HTTP Content-Type header for
    163   * this response, that will always be used, and this value is ignored. We
    164   * default to UTF-8 because that is a reasonable default.
    165   */
    166  charset: "utf-8",
    167 
    168  /**
    169   * Set a request header.
    170   */
    171  setHeader(name, value) {
    172    this._headers[name.toLowerCase()] = value;
    173  },
    174 
    175  /**
    176   * Perform an HTTP GET.
    177   *
    178   * @return Promise<RESTResponse>
    179   */
    180  async get() {
    181    return this.dispatch("GET", null);
    182  },
    183 
    184  /**
    185   * Perform an HTTP PATCH.
    186   *
    187   * @param data
    188   *        Data to be used as the request body. If this isn't a string
    189   *        it will be JSONified automatically.
    190   *
    191   * @return Promise<RESTResponse>
    192   */
    193  async patch(data) {
    194    return this.dispatch("PATCH", data);
    195  },
    196 
    197  /**
    198   * Perform an HTTP PUT.
    199   *
    200   * @param data
    201   *        Data to be used as the request body. If this isn't a string
    202   *        it will be JSONified automatically.
    203   *
    204   * @return Promise<RESTResponse>
    205   */
    206  async put(data) {
    207    return this.dispatch("PUT", data);
    208  },
    209 
    210  /**
    211   * Perform an HTTP POST.
    212   *
    213   * @param data
    214   *        Data to be used as the request body. If this isn't a string
    215   *        it will be JSONified automatically.
    216   *
    217   * @return Promise<RESTResponse>
    218   */
    219  async post(data) {
    220    return this.dispatch("POST", data);
    221  },
    222 
    223  /**
    224   * Perform an HTTP DELETE.
    225   *
    226   * @return Promise<RESTResponse>
    227   */
    228  async delete() {
    229    return this.dispatch("DELETE", null);
    230  },
    231 
    232  /**
    233   * Abort an active request.
    234   */
    235  abort(rejectWithError = null) {
    236    if (this.status != this.SENT && this.status != this.IN_PROGRESS) {
    237      throw new Error("Can only abort a request that has been sent.");
    238    }
    239 
    240    this.status = this.ABORTED;
    241    this.channel.cancel(Cr.NS_BINDING_ABORTED);
    242 
    243    if (this.timeoutTimer) {
    244      // Clear the abort timer now that the channel is done.
    245      this.timeoutTimer.clear();
    246    }
    247    if (rejectWithError) {
    248      this._deferred.reject(rejectWithError);
    249    }
    250  },
    251 
    252  /** Implementation stuff */
    253 
    254  async dispatch(method, data) {
    255    if (this.status != this.NOT_SENT) {
    256      throw new Error("Request has already been sent!");
    257    }
    258 
    259    this.method = method;
    260 
    261    // Create and initialize HTTP channel.
    262    let channel = NetUtil.newChannel({
    263      uri: this.uri,
    264      loadUsingSystemPrincipal: true,
    265    })
    266      .QueryInterface(Ci.nsIRequest)
    267      .QueryInterface(Ci.nsIHttpChannel);
    268    this.channel = channel;
    269    channel.loadFlags |= this.loadFlags;
    270    channel.notificationCallbacks = this;
    271 
    272    this._log.debug(`${method} request to ${this.uri.spec}`);
    273    // Set request headers.
    274    let headers = this._headers;
    275    for (let key in headers) {
    276      if (key == "authorization" || key == "x-client-state") {
    277        this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
    278      } else {
    279        this._log.trace("HTTP Header " + key + ": " + headers[key]);
    280      }
    281      channel.setRequestHeader(key, headers[key], false);
    282    }
    283 
    284    // REST requests accept JSON by default
    285    if (!headers.accept) {
    286      channel.setRequestHeader(
    287        "accept",
    288        "application/json;q=0.9,*/*;q=0.2",
    289        false
    290      );
    291    }
    292 
    293    // Set HTTP request body.
    294    if (method == "PUT" || method == "POST" || method == "PATCH") {
    295      // Convert non-string bodies into JSON with utf-8 encoding. If a string
    296      // is passed we assume they've already encoded it.
    297      let contentType = headers["content-type"];
    298      if (typeof data != "string") {
    299        data = JSON.stringify(data);
    300        if (!contentType) {
    301          contentType = "application/json";
    302        }
    303        if (!contentType.includes("charset")) {
    304          data = CommonUtils.encodeUTF8(data);
    305          contentType += "; charset=utf-8";
    306        } else {
    307          // If someone handed us an object but also a custom content-type
    308          // it's probably confused. We could go to even further lengths to
    309          // respect it, but this shouldn't happen in practice.
    310          console.error(
    311            "rest.js found an object to JSON.stringify but also a " +
    312              "content-type header with a charset specification. " +
    313              "This probably isn't going to do what you expect"
    314          );
    315        }
    316      }
    317      if (!contentType) {
    318        contentType = "text/plain";
    319      }
    320 
    321      this._log.debug(method + " Length: " + data.length);
    322      if (this._log.level <= Log.Level.Trace) {
    323        this._log.trace(method + " Body: " + data);
    324      }
    325 
    326      let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
    327        Ci.nsIStringInputStream
    328      );
    329      stream.setByteStringData(data);
    330 
    331      channel.QueryInterface(Ci.nsIUploadChannel);
    332      channel.setUploadStream(stream, contentType, data.length);
    333    }
    334    // We must set this after setting the upload stream, otherwise it
    335    // will always be 'PUT'. Yeah, I know.
    336    channel.requestMethod = method;
    337 
    338    // Before opening the channel, set the charset that serves as a hint
    339    // as to what the response might be encoded as.
    340    channel.contentCharset = this.charset;
    341 
    342    // Blast off!
    343    try {
    344      channel.asyncOpen(this);
    345    } catch (ex) {
    346      // asyncOpen can throw in a bunch of cases -- e.g., a forbidden port.
    347      this._log.warn("Caught an error in asyncOpen", ex);
    348      this._deferred.reject(ex);
    349    }
    350    this.status = this.SENT;
    351    this.delayTimeout();
    352    return this._deferred.promise;
    353  },
    354 
    355  /**
    356   * Create or push back the abort timer that kills this request.
    357   */
    358  delayTimeout() {
    359    if (this.timeout) {
    360      CommonUtils.namedTimer(
    361        this.abortTimeout,
    362        this.timeout * 1000,
    363        this,
    364        "timeoutTimer"
    365      );
    366    }
    367  },
    368 
    369  /**
    370   * Abort the request based on a timeout.
    371   */
    372  abortTimeout() {
    373    this.abort(
    374      Components.Exception(
    375        "Aborting due to channel inactivity.",
    376        Cr.NS_ERROR_NET_TIMEOUT
    377      )
    378    );
    379  },
    380 
    381  /** nsIStreamListener */
    382 
    383  onStartRequest(channel) {
    384    if (this.status == this.ABORTED) {
    385      this._log.trace(
    386        "Not proceeding with onStartRequest, request was aborted."
    387      );
    388      // We might have already rejected, but just in case.
    389      this._deferred.reject(
    390        Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
    391      );
    392      return;
    393    }
    394 
    395    try {
    396      channel.QueryInterface(Ci.nsIHttpChannel);
    397    } catch (ex) {
    398      this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
    399      this.status = this.ABORTED;
    400      channel.cancel(Cr.NS_BINDING_ABORTED);
    401      this._deferred.reject(ex);
    402      return;
    403    }
    404 
    405    this.status = this.IN_PROGRESS;
    406 
    407    this._log.trace(
    408      "onStartRequest: " + channel.requestMethod + " " + channel.URI.spec
    409    );
    410 
    411    // Create a new response object.
    412    this.response = new RESTResponse(this);
    413 
    414    this.delayTimeout();
    415  },
    416 
    417  onStopRequest(channel, statusCode) {
    418    if (this.timeoutTimer) {
    419      // Clear the abort timer now that the channel is done.
    420      this.timeoutTimer.clear();
    421    }
    422 
    423    // We don't want to do anything for a request that's already been aborted.
    424    if (this.status == this.ABORTED) {
    425      this._log.trace(
    426        "Not proceeding with onStopRequest, request was aborted."
    427      );
    428      // We might not have already rejected if the user called reject() manually.
    429      // If we have already rejected, then this is a no-op
    430      this._deferred.reject(
    431        Components.Exception("Request aborted", Cr.NS_BINDING_ABORTED)
    432      );
    433      return;
    434    }
    435 
    436    try {
    437      channel.QueryInterface(Ci.nsIHttpChannel);
    438    } catch (ex) {
    439      this._log.error("Unexpected error: channel not nsIHttpChannel!");
    440      this.status = this.ABORTED;
    441      this._deferred.reject(ex);
    442      return;
    443    }
    444 
    445    this.status = this.COMPLETED;
    446 
    447    try {
    448      this.response.body = decodeString(
    449        this.response._rawBody,
    450        this.response.charset
    451      );
    452      this.response._rawBody = null;
    453    } catch (ex) {
    454      this._log.warn(
    455        `Exception decoding response - ${this.method} ${channel.URI.spec}`,
    456        ex
    457      );
    458      this._deferred.reject(ex);
    459      return;
    460    }
    461 
    462    let statusSuccess = Components.isSuccessCode(statusCode);
    463    let uri = (channel && channel.URI && channel.URI.spec) || "<unknown>";
    464    this._log.trace(
    465      "Channel for " +
    466        channel.requestMethod +
    467        " " +
    468        uri +
    469        " returned status code " +
    470        statusCode
    471    );
    472 
    473    // Throw the failure code and stop execution.  Use Components.Exception()
    474    // instead of Error() so the exception is QI-able and can be passed across
    475    // XPCOM borders while preserving the status code.
    476    if (!statusSuccess) {
    477      let message = Components.Exception("", statusCode).name;
    478      let error = Components.Exception(message, statusCode);
    479      this._log.debug(
    480        this.method + " " + uri + " failed: " + statusCode + " - " + message
    481      );
    482      // Additionally give the full response body when Trace logging.
    483      if (this._log.level <= Log.Level.Trace) {
    484        this._log.trace(this.method + " body", this.response.body);
    485      }
    486      this._deferred.reject(error);
    487      return;
    488    }
    489 
    490    this._log.debug(this.method + " " + uri + " " + this.response.status);
    491 
    492    // Note that for privacy/security reasons we don't log this response body
    493 
    494    delete this._inputStream;
    495 
    496    this._deferred.resolve(this.response);
    497  },
    498 
    499  onDataAvailable(channel, stream, off, count) {
    500    // We get an nsIRequest, which doesn't have contentCharset.
    501    try {
    502      channel.QueryInterface(Ci.nsIHttpChannel);
    503    } catch (ex) {
    504      this._log.error("Unexpected error: channel not nsIHttpChannel!");
    505      this.abort(ex);
    506      return;
    507    }
    508 
    509    if (channel.contentCharset) {
    510      this.response.charset = channel.contentCharset;
    511    } else {
    512      this.response.charset = null;
    513    }
    514 
    515    if (!this._inputStream) {
    516      this._inputStream = Cc[
    517        "@mozilla.org/scriptableinputstream;1"
    518      ].createInstance(Ci.nsIScriptableInputStream);
    519    }
    520    this._inputStream.init(stream);
    521 
    522    this.response._rawBody += this._inputStream.read(count);
    523 
    524    this.delayTimeout();
    525  },
    526 
    527  /** nsIInterfaceRequestor */
    528 
    529  getInterface(aIID) {
    530    return this.QueryInterface(aIID);
    531  },
    532 
    533  /**
    534   * Returns true if headers from the old channel should be
    535   * copied to the new channel. Invoked when a channel redirect
    536   * is in progress.
    537   */
    538  shouldCopyOnRedirect(oldChannel, newChannel, flags) {
    539    let isInternal = !!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL);
    540    let isSameURI = newChannel.URI.equals(oldChannel.URI);
    541    this._log.debug(
    542      "Channel redirect: " +
    543        oldChannel.URI.spec +
    544        ", " +
    545        newChannel.URI.spec +
    546        ", internal = " +
    547        isInternal
    548    );
    549    return isInternal && isSameURI;
    550  },
    551 
    552  /** nsIChannelEventSink */
    553  asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
    554    let oldSpec =
    555      oldChannel && oldChannel.URI ? oldChannel.URI.spec : "<undefined>";
    556    let newSpec =
    557      newChannel && newChannel.URI ? newChannel.URI.spec : "<undefined>";
    558    this._log.debug(
    559      "Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags
    560    );
    561 
    562    try {
    563      newChannel.QueryInterface(Ci.nsIHttpChannel);
    564    } catch (ex) {
    565      this._log.error("Unexpected error: channel not nsIHttpChannel!");
    566      callback.onRedirectVerifyCallback(Cr.NS_ERROR_NO_INTERFACE);
    567      return;
    568    }
    569 
    570    // For internal redirects, copy the headers that our caller set.
    571    try {
    572      if (this.shouldCopyOnRedirect(oldChannel, newChannel, flags)) {
    573        this._log.trace("Copying headers for safe internal redirect.");
    574        for (let key in this._headers) {
    575          newChannel.setRequestHeader(key, this._headers[key], false);
    576        }
    577      }
    578    } catch (ex) {
    579      this._log.error("Error copying headers", ex);
    580    }
    581 
    582    this.channel = newChannel;
    583 
    584    // We let all redirects proceed.
    585    callback.onRedirectVerifyCallback(Cr.NS_OK);
    586  },
    587 };
    588 
    589 /**
    590 * Response object for a RESTRequest. This will be created automatically by
    591 * the RESTRequest.
    592 */
    593 export function RESTResponse(request = null) {
    594  this.body = "";
    595  this._rawBody = "";
    596  this.request = request;
    597  this._log = Log.repository.getLogger(this._logName);
    598  this._log.manageLevelFromPref("services.common.log.logger.rest.response");
    599 }
    600 
    601 RESTResponse.prototype = {
    602  _logName: "Services.Common.RESTResponse",
    603 
    604  /**
    605   * Corresponding REST request
    606   */
    607  request: null,
    608 
    609  /**
    610   * HTTP status code
    611   */
    612  get status() {
    613    let status;
    614    try {
    615      status = this.request.channel.responseStatus;
    616    } catch (ex) {
    617      this._log.debug("Caught exception fetching HTTP status code", ex);
    618      return null;
    619    }
    620    Object.defineProperty(this, "status", { value: status });
    621    return status;
    622  },
    623 
    624  /**
    625   * HTTP status text
    626   */
    627  get statusText() {
    628    let statusText;
    629    try {
    630      statusText = this.request.channel.responseStatusText;
    631    } catch (ex) {
    632      this._log.debug("Caught exception fetching HTTP status text", ex);
    633      return null;
    634    }
    635    Object.defineProperty(this, "statusText", { value: statusText });
    636    return statusText;
    637  },
    638 
    639  /**
    640   * Boolean flag that indicates whether the HTTP status code is 2xx or not.
    641   */
    642  get success() {
    643    let success;
    644    try {
    645      success = this.request.channel.requestSucceeded;
    646    } catch (ex) {
    647      this._log.debug("Caught exception fetching HTTP success flag", ex);
    648      return null;
    649    }
    650    Object.defineProperty(this, "success", { value: success });
    651    return success;
    652  },
    653 
    654  /**
    655   * Object containing HTTP headers (keyed as lower case)
    656   */
    657  get headers() {
    658    let headers = {};
    659    try {
    660      this._log.trace("Processing response headers.");
    661      let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
    662      channel.visitResponseHeaders(function (header, value) {
    663        headers[header.toLowerCase()] = value;
    664      });
    665    } catch (ex) {
    666      this._log.debug("Caught exception processing response headers", ex);
    667      return null;
    668    }
    669 
    670    Object.defineProperty(this, "headers", { value: headers });
    671    return headers;
    672  },
    673 
    674  /**
    675   * HTTP body (string)
    676   */
    677  body: null,
    678 };
    679 
    680 /**
    681 * Single use MAC authenticated HTTP requests to RESTish resources.
    682 *
    683 * @param uri
    684 *        URI going to the RESTRequest constructor.
    685 * @param authToken
    686 *        (Object) An auth token of the form {id: (string), key: (string)}
    687 *        from which the MAC Authentication header for this request will be
    688 *        derived. A token as obtained from
    689 *        TokenServerClient.getTokenUsingOAuth is accepted.
    690 * @param extra
    691 *        (Object) Optional extra parameters. Valid keys are: nonce_bytes, ts,
    692 *        nonce, and ext. See CrytoUtils.computeHTTPMACSHA1 for information on
    693 *        the purpose of these values.
    694 */
    695 export function TokenAuthenticatedRESTRequest(uri, authToken, extra) {
    696  RESTRequest.call(this, uri);
    697  this.authToken = authToken;
    698  this.extra = extra || {};
    699 }
    700 
    701 TokenAuthenticatedRESTRequest.prototype = {
    702  async dispatch(method, data) {
    703    let sig = await lazy.CryptoUtils.computeHTTPMACSHA1(
    704      this.authToken.id,
    705      this.authToken.key,
    706      method,
    707      this.uri,
    708      this.extra
    709    );
    710 
    711    this.setHeader("Authorization", sig.getHeader());
    712 
    713    return super.dispatch(method, data);
    714  },
    715 };
    716 
    717 Object.setPrototypeOf(
    718  TokenAuthenticatedRESTRequest.prototype,
    719  RESTRequest.prototype
    720 );