tor-browser

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

RemoteSettingsServer.sys.mjs (19129B)


      1 /* Any copyright is dedicated to the Public Domain.
      2   http://creativecommons.org/publicdomain/zero/1.0/ */
      3 
      4 /* eslint-disable jsdoc/require-param-description */
      5 
      6 const lazy = {};
      7 
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  HttpError: "resource://testing-common/httpd.sys.mjs",
     10  HttpServer: "resource://testing-common/httpd.sys.mjs",
     11  HTTP_404: "resource://testing-common/httpd.sys.mjs",
     12 });
     13 
     14 /**
     15 * @import {HttpError} from "resource://testing-common/httpd.sys.mjs"
     16 */
     17 
     18 const SERVER_PREF = "services.settings.server";
     19 
     20 /**
     21 * A remote settings server. Tested with the desktop and Rust remote settings
     22 * clients.
     23 */
     24 export class RemoteSettingsServer {
     25  /**
     26   * The server must be started by calling `start()`.
     27   *
     28   * @param {object} options
     29   * @param {ConsoleLogLevel} [options.maxLogLevel]
     30   *   A log level value as defined by ConsoleInstance. `Info` logs server start
     31   *   and stop. `Debug` logs requests, responses, and added and removed
     32   *   records.
     33   */
     34  constructor({ maxLogLevel = "Info" } = {}) {
     35    this.#log = console.createInstance({
     36      prefix: "RemoteSettingsServer",
     37      maxLogLevel,
     38    });
     39  }
     40 
     41  /**
     42   * @returns {URL}
     43   *   The server's URL. Null when the server is stopped.
     44   */
     45  get url() {
     46    return this.#url;
     47  }
     48 
     49  /**
     50   * Starts the server and sets the `services.settings.server` pref to its
     51   * URL. The server's `url` property will be non-null on return.
     52   */
     53  async start() {
     54    this.#log.info("Starting");
     55 
     56    if (this.#url) {
     57      this.#log.info("Already started at " + this.#url);
     58      return;
     59    }
     60 
     61    if (!this.#server) {
     62      this.#server = new lazy.HttpServer();
     63      this.#server.registerPrefixHandler("/", this);
     64    }
     65    this.#server.start(-1);
     66 
     67    this.#url = new URL("http://localhost/v1");
     68    this.#url.port = this.#server.identity.primaryPort;
     69 
     70    this.#originalServerPrefValue = Services.prefs.getCharPref(
     71      SERVER_PREF,
     72      null
     73    );
     74    Services.prefs.setCharPref(SERVER_PREF, this.#url.toString());
     75 
     76    this.#log.info("Server is now started at " + this.#url);
     77  }
     78 
     79  /**
     80   * Stops the server and clears the `services.settings.server` pref. The
     81   * server's `url` property will be null on return.
     82   */
     83  async stop() {
     84    this.#log.info("Stopping");
     85 
     86    if (!this.#url) {
     87      this.#log.info("Already stopped");
     88      return;
     89    }
     90 
     91    await this.#server.stop();
     92    this.#url = null;
     93 
     94    if (this.#originalServerPrefValue === null) {
     95      Services.prefs.clearUserPref(SERVER_PREF);
     96    } else {
     97      Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue);
     98    }
     99 
    100    this.#log.info("Server is now stopped");
    101  }
    102 
    103  /**
    104   * Adds remote settings records to the server. Records may have attachments;
    105   * see the param doc below.
    106   *
    107   * @param {object} options
    108   * @param {string} options.bucket
    109   * @param {string} options.collection
    110   * @param {Array} options.records
    111   *   Each object in this array should be a realistic remote settings record
    112   *   with the following exceptions:
    113   *
    114   *   - `record.id` will be generated if it's undefined.
    115   *   - `record.last_modified` will be set to the `#lastModified` property of
    116   *     the server if it's undefined.
    117   *   - `record.attachment`, if defined, should be the attachment itself and
    118   *     not its metadata. The server will automatically create some dummy
    119   *     metadata. Currently the only supported attachment type is plain
    120   *     JSON'able objects that the server will convert to JSON in responses.
    121   */
    122  async addRecords({ bucket = "main", collection = "test", records }) {
    123    this.#log.debug("Adding records:", { bucket, collection, records });
    124 
    125    this.#lastModified++;
    126 
    127    let key = this.#recordsKey(bucket, collection);
    128    let allRecords = this.#records.get(key);
    129    if (!allRecords) {
    130      allRecords = [];
    131      this.#records.set(key, allRecords);
    132    }
    133 
    134    for (let record of records) {
    135      let copy = { ...record };
    136 
    137      if (!copy.hasOwnProperty("id")) {
    138        copy.id = String(this.#nextRecordId++);
    139      }
    140      if (!copy.hasOwnProperty("last_modified")) {
    141        copy.last_modified = this.#lastModified;
    142      }
    143      if (copy.attachment) {
    144        await this.#addAttachment({ bucket, collection, record: copy });
    145      }
    146      allRecords.push(copy);
    147    }
    148 
    149    this.#log.debug("Done adding records. All records are now:", [
    150      ...this.#records.entries(),
    151    ]);
    152  }
    153 
    154  /**
    155   * Marks records as deleted. Deleted records will still be returned in
    156   * responses, but they'll have a `deleted = true` property. Their attachments
    157   * will be deleted immediately, however.
    158   *
    159   * @param {object} filter
    160   *   If null, all records will be marked as deleted. Otherwise only records
    161   *   that match the filter will be marked as deleted. For a given record, each
    162   *   value in the filter object will be compared to the value with the same
    163   *   key in the record. If all values are the same, the record will be
    164   *   removed. Examples:
    165   *
    166   *   To remove remove records whose `type` key has the value "data":
    167   *   `{ type: "data" }
    168   *
    169   *   To remove remove records whose `type` key has the value "data" and whose
    170   *   `last_modified` key has the value 1234:
    171   *   `{ type: "data", last_modified: 1234 }
    172   */
    173  removeRecords(filter = null) {
    174    this.#log.debug("Removing records", { filter });
    175 
    176    this.#lastModified++;
    177 
    178    for (let records of this.#records.values()) {
    179      for (let record of records) {
    180        if (
    181          !filter ||
    182          Object.entries(filter).every(
    183            ([filterKey, filterValue]) =>
    184              record.hasOwnProperty(filterKey) &&
    185              record[filterKey] == filterValue
    186          )
    187        ) {
    188          record.deleted = true;
    189          record.last_modified = this.#lastModified;
    190 
    191          // If the record has an attachment, leave it. Sometimes the following
    192          // sequence can happen: A test requests records, we send them,
    193          // something else deletes the records, and then the test requests
    194          // their attachments. The JS RS client throws an error in that case
    195          // since the attachment hashes don't match the hashes in the records.
    196        }
    197      }
    198    }
    199 
    200    this.#log.debug("Done removing records. All records are now:", [
    201      ...this.#records.entries(),
    202    ]);
    203  }
    204 
    205  /**
    206   * Removes all existing records and adds the given records to the server.
    207   *
    208   * @param {object} options
    209   * @param {string} options.bucket
    210   * @param {string} options.collection
    211   * @param {Array} options.records
    212   *   See `addRecords()`.
    213   */
    214  async setRecords({ bucket = "main", collection = "test", records }) {
    215    this.#log.debug("Setting records");
    216 
    217    this.removeRecords();
    218    await this.addRecords({ bucket, collection, records });
    219 
    220    this.#log.debug("Done setting records");
    221  }
    222 
    223  /**
    224   * `nsIHttpRequestHandler` callback from the backing server. Handles a
    225   * request.
    226   *
    227   * @param {nsIHttpRequest} request
    228   * @param {nsIHttpResponse} response
    229   */
    230  handle(request, response) {
    231    this.#logRequest(request);
    232 
    233    // Get the route that matches the request path.
    234    let { match, route } = this.#getRoute(request.path) || {};
    235    if (!route) {
    236      this.#prepareError({ request, response, error: lazy.HTTP_404 });
    237      return;
    238    }
    239 
    240    let respInfo = route.response(match, request, response);
    241    if (respInfo instanceof lazy.HttpError) {
    242      this.#prepareError({ request, response, error: respInfo });
    243    } else {
    244      this.#prepareResponse({ ...respInfo, request, response });
    245    }
    246  }
    247 
    248  /**
    249   * @returns {Array}
    250   *   The routes handled by the server. Each item in this array is an object
    251   *   with the following properties that describes one or more paths and the
    252   *   response that should be sent when a request is made on those paths:
    253   *
    254   *   {string} spec
    255   *     A path spec. This is required unless `specs` is defined. To determine
    256   *     which route should be used for a given request, the server will check
    257   *     each route's spec(s) until it finds the first that matches the
    258   *     request's path. A spec is just a path whose components can be variables
    259   *     that start with "$". When a spec with variables matches a request path,
    260   *     the `match` object passed to the route's `response` function will map
    261   *     from variable names to the corresponding components in the path.
    262   *   {Array} specs
    263   *     An array of path spec strings. Use this instead of `spec` if the route
    264   *     handles more than one.
    265   *   {function} response
    266   *     A function that will be called when the route matches a request. It is
    267   *     called as: `response(match, request, response)`
    268   *
    269   *     {object} match
    270   *       An object mapping variable names in the spec to their matched
    271   *       components in the path. See `#match()` for details.
    272   *     {nsIHttpRequest} request
    273   *     {nsIHttpResponse} response
    274   *
    275   *     The function must return one of the following:
    276   *
    277   *     {object}
    278   *       An object that describes the response with the following properties:
    279   *       {object} body
    280   *         A plain JSON'able object. The server will convert this to JSON and
    281   *         set it to the response body.
    282   *     {HttpError}
    283   *       An `HttpError` instance defined in `httpd.sys.mjs`.
    284   */
    285  get #routes() {
    286    return [
    287      {
    288        spec: "/v1",
    289        response: () => ({
    290          body: {
    291            capabilities: {
    292              attachments: {
    293                base_url: this.#url.toString(),
    294              },
    295            },
    296          },
    297        }),
    298      },
    299 
    300      {
    301        spec: "/v1/buckets/monitor/collections/changes/changeset",
    302        response: () => ({
    303          body: {
    304            timestamp: this.#lastModified,
    305            changes: [
    306              {
    307                last_modified: this.#lastModified,
    308              },
    309            ],
    310          },
    311        }),
    312      },
    313 
    314      {
    315        spec: "/v1/buckets/$bucket/collections/$collection/changeset",
    316        response: ({ bucket, collection }, request) => {
    317          let records = this.#getRecords(bucket, collection, request);
    318          return !records
    319            ? lazy.HTTP_404
    320            : {
    321                body: {
    322                  metadata: {
    323                    bucket,
    324                    signature: {
    325                      signature: "",
    326                      x5u: "",
    327                    },
    328                  },
    329                  timestamp: this.#lastModified,
    330                  changes: records,
    331                },
    332              };
    333        },
    334      },
    335 
    336      {
    337        spec: "/v1/buckets/$bucket/collections/$collection/records",
    338        response: ({ bucket, collection }, request) => {
    339          let records = this.#getRecords(bucket, collection, request);
    340          return !records
    341            ? lazy.HTTP_404
    342            : {
    343                body: {
    344                  data: records,
    345                },
    346              };
    347        },
    348      },
    349 
    350      {
    351        specs: [
    352          // The Rust remote settings client doesn't include "v1" in attachment
    353          // URLs, but the JS client does.
    354          "/attachments/$bucket/$collection/$filename",
    355          "/v1/attachments/$bucket/$collection/$filename",
    356        ],
    357        response: ({ bucket, collection, filename }) => {
    358          return {
    359            body: this.#getAttachment(bucket, collection, filename),
    360          };
    361        },
    362      },
    363    ];
    364  }
    365 
    366  /**
    367   * @returns {object}
    368   *   Default response headers.
    369   */
    370  get #responseHeaders() {
    371    return {
    372      "Access-Control-Allow-Origin": "*",
    373      "Access-Control-Expose-Headers":
    374        "Retry-After, Content-Length, Alert, Backoff",
    375      Server: "waitress",
    376      Etag: `"${this.#lastModified}"`,
    377    };
    378  }
    379 
    380  /**
    381   * Returns the route that matches a request path.
    382   *
    383   * @param {string} path
    384   *   A request path.
    385   * @returns {object}
    386   *   If no route matches the path, returns an empty object. Otherwise returns
    387   *   an object with the following properties:
    388   *
    389   *   {object} match
    390   *     An object describing the matched variables in the route spec. See
    391   *     `#match()` for details.
    392   *   {object} route
    393   *     The matched route. See `#routes` for details.
    394   */
    395  #getRoute(path) {
    396    for (let route of this.#routes) {
    397      let specs = route.specs || [route.spec];
    398      for (let spec of specs) {
    399        let match = this.#match(path, spec);
    400        if (match) {
    401          return { match, route };
    402        }
    403      }
    404    }
    405    return {};
    406  }
    407 
    408  /**
    409   * Matches a request path to a route spec.
    410   *
    411   * @param {string} path
    412   *   A request path.
    413   * @param {string} spec
    414   *   A route spec. See `#routes` for details.
    415   * @returns {object|null}
    416   *   If the spec doesn't match the path, returns null. Otherwise returns an
    417   *   object mapping variable names in the spec to their matched components in
    418   *   the path. Example:
    419   *
    420   *   path   : "/main/myfeature/foo"
    421   *   spec   : "/$bucket/$collection/foo"
    422   *   returns: `{ bucket: "main", collection: "myfeature" }`
    423   */
    424  #match(path, spec) {
    425    let pathParts = path.split("/");
    426    let specParts = spec.split("/");
    427 
    428    if (pathParts.length != specParts.length) {
    429      // If the path has only one more part than the spec and its last part is
    430      // empty, then the path ends in a trailing slash but the spec does not.
    431      // Consider that a match. Otherwise return null for no match.
    432      if (
    433        pathParts[pathParts.length - 1] ||
    434        pathParts.length != specParts.length + 1
    435      ) {
    436        return null;
    437      }
    438      pathParts.pop();
    439    }
    440 
    441    let match = {};
    442    for (let i = 0; i < pathParts.length; i++) {
    443      let pathPart = pathParts[i];
    444      let specPart = specParts[i];
    445      if (specPart.startsWith("$")) {
    446        match[specPart.substring(1)] = pathPart;
    447      } else if (pathPart != specPart) {
    448        return null;
    449      }
    450    }
    451 
    452    return match;
    453  }
    454 
    455  #getRecords(bucket, collection, request) {
    456    let records = this.#records.get(this.#recordsKey(bucket, collection));
    457    let params = new URLSearchParams(request.queryString);
    458 
    459    let type = params.get("type");
    460    if (type) {
    461      records = records.filter(r => r.type == type);
    462    }
    463 
    464    let gtLastModified = params.get("gt_last_modified");
    465    if (gtLastModified) {
    466      records = records.filter(r => r.last_modified > gtLastModified);
    467    }
    468 
    469    let since = params.get("_since");
    470    if (since) {
    471      // Example value: "%221368273600004%22"
    472      let match = /^"([0-9]+)"$/.exec(decodeURIComponent(since));
    473      if (match) {
    474        let sinceTime = parseInt(match[1]);
    475        records = records.filter(r => r.last_modified > sinceTime);
    476      }
    477    }
    478 
    479    let sort = params.get("_sort");
    480    if (sort == "last_modified") {
    481      records = records.toSorted((a, b) => a.last_modified - b.last_modified);
    482    }
    483 
    484    return records;
    485  }
    486 
    487  #recordsKey(bucket, collection) {
    488    return `${bucket}/${collection}`;
    489  }
    490 
    491  /**
    492   * Registers an attachment for a record.
    493   *
    494   * @param {object} options
    495   * @param {string} options.bucket
    496   * @param {string} options.collection
    497   * @param {object} options.record
    498   *   The record should have an `attachment` property as described in
    499   *   `addRecords()`.
    500   */
    501  async #addAttachment({ bucket, collection, record }) {
    502    let { attachment } = record;
    503 
    504    let mimetype =
    505      record.attachmentMimetype ?? "application/json; charset=UTF-8";
    506    if (!mimetype.startsWith("application/json")) {
    507      throw new Error(
    508        "Mimetype not handled, please add code for it! " + mimetype
    509      );
    510    }
    511 
    512    let encoder = new TextEncoder();
    513    let bytes = encoder.encode(JSON.stringify(attachment));
    514 
    515    let hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
    516    let hashBytes = new Uint8Array(hashBuffer);
    517    let toHex = b => b.toString(16).padStart(2, "0");
    518    let hash = Array.from(hashBytes, toHex).join("");
    519 
    520    let filename = record.id;
    521    this.#attachments.set(
    522      this.#attachmentsKey(bucket, collection, filename),
    523      attachment
    524    );
    525 
    526    // Replace `record.attachment` with appropriate metadata in order to conform
    527    // with the remote settings API.
    528    record.attachment = {
    529      hash,
    530      filename,
    531      mimetype,
    532      size: bytes.length,
    533      location: `attachments/${bucket}/${collection}/${filename}`,
    534    };
    535 
    536    delete record.attachmentMimetype;
    537  }
    538 
    539  #attachmentsKey(bucket, collection, filename) {
    540    return `${bucket}/${collection}/${filename}`;
    541  }
    542 
    543  #getAttachment(bucket, collection, filename) {
    544    return this.#attachments.get(
    545      this.#attachmentsKey(bucket, collection, filename)
    546    );
    547  }
    548 
    549  /**
    550   * Prepares an HTTP response.
    551   *
    552   * @param {object} options
    553   * @param {nsIHttpRequest} options.request
    554   * @param {nsIHttpResponse} options.response
    555   * @param {object|null} [options.body]
    556   *   Currently only JSON'able objects are supported. They will be converted to
    557   *   JSON in the response.
    558   * @param {number} [options.status]
    559   * @param {string} [options.statusText]
    560   */
    561  #prepareResponse({
    562    request,
    563    response,
    564    body = null,
    565    status = 200,
    566    statusText = "OK",
    567  }) {
    568    let headers = { ...this.#responseHeaders };
    569    if (body) {
    570      headers["Content-Type"] = "application/json; charset=UTF-8";
    571    }
    572 
    573    this.#logResponse({ request, status, body });
    574 
    575    for (let [name, value] of Object.entries(headers)) {
    576      response.setHeader(name, value, false);
    577    }
    578    if (body) {
    579      response.write(JSON.stringify(body));
    580    }
    581    response.setStatusLine(request.httpVersion, status, statusText);
    582  }
    583 
    584  /**
    585   * Prepares an HTTP error response.
    586   *
    587   * @param {object} options
    588   * @param {nsIHttpRequest} options.request
    589   * @param {nsIHttpResponse} options.response
    590   * @param {HttpError} options.error
    591   *   An `HttpError` instance defined in `httpd.sys.mjs`.
    592   */
    593  #prepareError({ request, response, error }) {
    594    this.#prepareResponse({
    595      request,
    596      response,
    597      status: error.code,
    598      statusText: error.description,
    599    });
    600  }
    601 
    602  /**
    603   * Logs a request.
    604   *
    605   * @param {nsIHttpRequest} request
    606   */
    607  #logRequest(request) {
    608    let pathAndQuery = request.path;
    609    if (request.queryString) {
    610      pathAndQuery += "?" + request.queryString;
    611    }
    612    this.#log.debug(
    613      `< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}`
    614    );
    615  }
    616 
    617  /**
    618   * Logs a response.
    619   *
    620   * @param {object} options
    621   * @param {nsIHttpRequest} options.request
    622   *   The associated request.
    623   * @param {number} options.status
    624   *   The HTTP status code of the response.
    625   * @param {object} options.body
    626   *   The response body, if any.
    627   */
    628  #logResponse({ request, status, body }) {
    629    this.#log.debug(`> ${status} ${request.path}`);
    630    if (body) {
    631      this.#log.debug("Response body:", body);
    632    }
    633  }
    634 
    635  // records key (see `#recordsKey()`) -> array of record objects
    636  #records = new Map();
    637 
    638  // attachments key (see `#attachmentsKey()`) -> attachment object
    639  #attachments = new Map();
    640 
    641  #log;
    642  #server;
    643  #originalServerPrefValue;
    644  #url = null;
    645  #lastModified = 1368273600000;
    646  #nextRecordId = 1;
    647 }