tor-browser

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

getHSTSPreloadList.js (15725B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      2 "use strict";
      3 
      4 // How to run this file:
      5 // 1. [obtain firefox source code]
      6 // 2. [build/obtain firefox binaries]
      7 // 3. run `[path to]/firefox -xpcshell [path to]/getHSTSPreloadlist.js [absolute path to]/nsSTSPreloadlist.inc'
      8 // Note: Running this file outputs a new nsSTSPreloadlist.inc in the current
      9 //       working directory.
     10 
     11 var gSSService = Cc["@mozilla.org/ssservice;1"].getService(
     12  Ci.nsISiteSecurityService
     13 );
     14 
     15 const { FileUtils } = ChromeUtils.importESModule(
     16  "resource://gre/modules/FileUtils.sys.mjs"
     17 );
     18 
     19 const SOURCE =
     20  "https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/http/transport_security_state_static.json?format=TEXT";
     21 const TOOL_SOURCE =
     22  "https://hg.mozilla.org/mozilla-central/file/default/taskcluster/docker/periodic-updates/scripts/getHSTSPreloadList.js";
     23 const OUTPUT = "nsSTSPreloadList.inc";
     24 const MINIMUM_REQUIRED_MAX_AGE = 60 * 60 * 24 * 7 * 18;
     25 const MAX_CONCURRENT_REQUESTS = 500;
     26 const MAX_RETRIES = 1;
     27 const REQUEST_TIMEOUT = 30 * 1000;
     28 const ERROR_NONE = "no error";
     29 const ERROR_CONNECTING_TO_HOST = "could not connect to host";
     30 const ERROR_NO_HSTS_HEADER = "did not receive HSTS header";
     31 const ERROR_MAX_AGE_TOO_LOW = "max-age too low: ";
     32 const HEADER = `/* This Source Code Form is subject to the terms of the Mozilla Public
     33 * License, v. 2.0. If a copy of the MPL was not distributed with this
     34 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     35 
     36 /*****************************************************************************/
     37 /* This is an automatically generated file. If you're not                    */
     38 /* nsSiteSecurityService.cpp, you shouldn't be #including it.                */
     39 /*****************************************************************************/
     40 
     41 #include <stdint.h>
     42 `;
     43 
     44 const GPERF_DELIM = "%%\n";
     45 
     46 async function download() {
     47  var resp = await fetch(SOURCE);
     48  if (resp.status != 200) {
     49    throw new Error(
     50      "ERROR: problem downloading '" + SOURCE + "': status " + resp.status
     51    );
     52  }
     53 
     54  let text = await resp.text();
     55  let resultDecoded;
     56  try {
     57    resultDecoded = atob(text);
     58  } catch (e) {
     59    throw new Error(
     60      "ERROR: could not decode data as base64 from '" + SOURCE + "': " + e
     61    );
     62  }
     63 
     64  // we have to filter out '//' comments, while not mangling the json
     65  let result = resultDecoded.replace(/^(\s*)?\/\/[^\n]*\n/gm, "");
     66  let data = null;
     67  try {
     68    data = JSON.parse(result);
     69  } catch (e) {
     70    throw new Error(`ERROR: could not parse data from '${SOURCE}': ${e}`);
     71  }
     72  return data;
     73 }
     74 
     75 function getHosts(rawdata) {
     76  let hosts = [];
     77 
     78  if (!rawdata || !rawdata.entries) {
     79    throw new Error(
     80      "ERROR: source data not formatted correctly: 'entries' not found"
     81    );
     82  }
     83 
     84  for (let entry of rawdata.entries) {
     85    if (entry.mode && entry.mode == "force-https") {
     86      if (entry.name) {
     87        // We trim the entry name here to avoid malformed URI exceptions when we
     88        // later try to connect to the domain.
     89        entry.name = entry.name.trim();
     90        entry.retries = MAX_RETRIES;
     91        // We prefer the camelCase variable to the JSON's snake case version
     92        entry.includeSubdomains = entry.include_subdomains;
     93        hosts.push(entry);
     94      } else {
     95        throw new Error("ERROR: entry not formatted correctly: no name found");
     96      }
     97    }
     98  }
     99 
    100  return hosts;
    101 }
    102 
    103 function processStsHeader(host, header, status, securityInfo) {
    104  let maxAge = {
    105    value: 0,
    106  };
    107  let includeSubdomains = {
    108    value: false,
    109  };
    110  let error = ERROR_NONE;
    111  if (
    112    header != null &&
    113    securityInfo != null &&
    114    securityInfo.overridableErrorCategory ==
    115      Ci.nsITransportSecurityInfo.ERROR_UNSET
    116  ) {
    117    try {
    118      let uri = Services.io.newURI("https://" + host.name);
    119      gSSService.processHeader(uri, header, {}, maxAge, includeSubdomains);
    120    } catch (e) {
    121      dump(
    122        "ERROR: could not process header '" +
    123          header +
    124          "' from " +
    125          host.name +
    126          ": " +
    127          e +
    128          "\n"
    129      );
    130      error = e;
    131    }
    132  } else if (status == 0) {
    133    error = ERROR_CONNECTING_TO_HOST;
    134  } else {
    135    error = ERROR_NO_HSTS_HEADER;
    136  }
    137 
    138  if (error == ERROR_NONE && maxAge.value < MINIMUM_REQUIRED_MAX_AGE) {
    139    error = ERROR_MAX_AGE_TOO_LOW;
    140  }
    141 
    142  return {
    143    name: host.name,
    144    maxAge: maxAge.value,
    145    includeSubdomains: includeSubdomains.value,
    146    error,
    147    retries: host.retries - 1,
    148    forceInclude: host.forceInclude,
    149  };
    150 }
    151 
    152 // RedirectAndAuthStopper prevents redirects and HTTP authentication
    153 function RedirectAndAuthStopper() {}
    154 
    155 RedirectAndAuthStopper.prototype = {
    156  // nsIChannelEventSink
    157  asyncOnChannelRedirect() {
    158    throw Components.Exception("", Cr.NS_ERROR_ENTITY_CHANGED);
    159  },
    160 
    161  // nsIAuthPrompt2
    162  promptAuth() {
    163    return false;
    164  },
    165 
    166  asyncPromptAuth() {
    167    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
    168  },
    169 
    170  getInterface(iid) {
    171    return this.QueryInterface(iid);
    172  },
    173 
    174  QueryInterface: ChromeUtils.generateQI([
    175    "nsIChannelEventSink",
    176    "nsIAuthPrompt2",
    177  ]),
    178 };
    179 
    180 function fetchstatus(host) {
    181  return new Promise(resolve => {
    182    let xhr = new XMLHttpRequest();
    183    let uri = "https://" + host.name + "/";
    184 
    185    xhr.open("head", uri, true);
    186    xhr.setRequestHeader("X-Automated-Tool", TOOL_SOURCE);
    187    xhr.timeout = REQUEST_TIMEOUT;
    188 
    189    let errorHandler = () => {
    190      dump("ERROR: exception making request to " + host.name + "\n");
    191      resolve(
    192        processStsHeader(
    193          host,
    194          null,
    195          xhr.status,
    196          xhr.channel && xhr.channel.securityInfo
    197        )
    198      );
    199    };
    200 
    201    xhr.onerror = errorHandler;
    202    xhr.ontimeout = errorHandler;
    203    xhr.onabort = errorHandler;
    204 
    205    xhr.onload = () => {
    206      let header = xhr.getResponseHeader("strict-transport-security");
    207      resolve(
    208        processStsHeader(host, header, xhr.status, xhr.channel.securityInfo)
    209      );
    210    };
    211 
    212    xhr.channel.notificationCallbacks = new RedirectAndAuthStopper();
    213    xhr.send();
    214  });
    215 }
    216 
    217 async function getHSTSStatus(host) {
    218  do {
    219    host = await fetchstatus(host);
    220  } while (shouldRetry(host));
    221  return host;
    222 }
    223 
    224 function compareHSTSStatus(a, b) {
    225  if (a.name > b.name) {
    226    return 1;
    227  }
    228  if (a.name < b.name) {
    229    return -1;
    230  }
    231  return 0;
    232 }
    233 
    234 function writeTo(string, fos) {
    235  fos.write(string, string.length);
    236 }
    237 
    238 // Determines and returns a string representing a declaration of when this
    239 // preload list should no longer be used.
    240 // This is the current time plus MINIMUM_REQUIRED_MAX_AGE.
    241 function getExpirationTimeString() {
    242  let now = new Date();
    243  let nowMillis = now.getTime();
    244  // MINIMUM_REQUIRED_MAX_AGE is in seconds, so convert to milliseconds
    245  let expirationMillis = nowMillis + MINIMUM_REQUIRED_MAX_AGE * 1000;
    246  let expirationMicros = expirationMillis * 1000;
    247  return (
    248    "const PRTime gPreloadListExpirationTime = INT64_C(" +
    249    expirationMicros +
    250    ");\n"
    251  );
    252 }
    253 
    254 function shouldRetry(response) {
    255  return (
    256    response.error != ERROR_NO_HSTS_HEADER &&
    257    response.error != ERROR_MAX_AGE_TOO_LOW &&
    258    response.error != ERROR_NONE &&
    259    response.retries > 0
    260  );
    261 }
    262 
    263 // Copied from browser/components/migration/MigrationUtils.sys.mjs
    264 function spinResolve(promise) {
    265  if (!(promise instanceof Promise)) {
    266    return promise;
    267  }
    268  let done = false;
    269  let result = null;
    270  let error = null;
    271  promise
    272    .catch(e => {
    273      error = e;
    274    })
    275    .then(r => {
    276      result = r;
    277      done = true;
    278    });
    279 
    280  Services.tm.spinEventLoopUntil(
    281    "getHSTSPreloadList.js:spinResolve",
    282    () => done
    283  );
    284  if (error) {
    285    throw error;
    286  } else {
    287    return result;
    288  }
    289 }
    290 
    291 async function probeHSTSStatuses(inHosts) {
    292  let totalLength = inHosts.length;
    293  dump("Examining " + totalLength + " hosts.\n");
    294 
    295  // Make requests in batches of MAX_CONCURRENT_REQUESTS. Otherwise, we have
    296  // too many in-flight requests and the time it takes to process them causes
    297  // them all to time out.
    298  let allResults = [];
    299  while (inHosts.length) {
    300    let promises = [];
    301    for (let i = 0; i < MAX_CONCURRENT_REQUESTS && inHosts.length; i++) {
    302      let host = inHosts.shift();
    303      promises.push(getHSTSStatus(host));
    304    }
    305    let results = await Promise.all(promises);
    306    let progress = (
    307      (100 * (totalLength - inHosts.length)) /
    308      totalLength
    309    ).toFixed(2);
    310    dump(progress + "% done\n");
    311    allResults = allResults.concat(results);
    312  }
    313 
    314  dump("HSTS Probe received " + allResults.length + " statuses.\n");
    315  return allResults;
    316 }
    317 
    318 function readCurrentList(filename) {
    319  var currentHosts = {};
    320  var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
    321  file.initWithPath(filename);
    322  var fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
    323    Ci.nsILineInputStream
    324  );
    325  fis.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF);
    326  var line = {};
    327 
    328  // While we generate entries matching the latest version format,
    329  // we still need to be able to read entries in the previous version formats
    330  // for bootstrapping a latest version preload list from a previous version
    331  // preload list. Hence these regexes.
    332  const entryRegexes = [
    333    /([^,]+), (0|1)/, // v3
    334    / {2}\/\* "([^"]*)", (true|false) \*\//, // v2
    335    / {2}{ "([^"]*)", (true|false) },/, // v1
    336  ];
    337 
    338  while (fis.readLine(line)) {
    339    let match;
    340    entryRegexes.find(r => {
    341      match = r.exec(line.value);
    342      return match;
    343    });
    344    if (match) {
    345      currentHosts[match[1]] = match[2] == "1" || match[2] == "true";
    346    }
    347  }
    348  return currentHosts;
    349 }
    350 
    351 function combineLists(newHosts, currentHosts) {
    352  let newHostsSet = new Set();
    353 
    354  for (let newHost of newHosts) {
    355    newHostsSet.add(newHost.name);
    356  }
    357 
    358  for (let currentHost in currentHosts) {
    359    if (!newHostsSet.has(currentHost)) {
    360      newHosts.push({ name: currentHost, retries: MAX_RETRIES });
    361    }
    362  }
    363 }
    364 
    365 const TEST_ENTRIES = [
    366  {
    367    name: "includesubdomains.preloaded.test",
    368    includeSubdomains: true,
    369  },
    370  {
    371    name: "includesubdomains2.preloaded.test",
    372    includeSubdomains: true,
    373  },
    374  {
    375    name: "noincludesubdomains.preloaded.test",
    376    includeSubdomains: false,
    377  },
    378 ];
    379 
    380 function deleteTestHosts(currentHosts) {
    381  for (let testEntry of TEST_ENTRIES) {
    382    delete currentHosts[testEntry.name];
    383  }
    384 }
    385 
    386 function getTestHosts() {
    387  let hosts = [];
    388  for (let testEntry of TEST_ENTRIES) {
    389    hosts.push({
    390      name: testEntry.name,
    391      maxAge: MINIMUM_REQUIRED_MAX_AGE,
    392      includeSubdomains: testEntry.includeSubdomains,
    393      error: ERROR_NONE,
    394      // This deliberately doesn't have a value for `retries` (because we should
    395      // never attempt to connect to this host).
    396      forceInclude: true,
    397    });
    398  }
    399  return hosts;
    400 }
    401 
    402 async function insertHosts(inoutHostList, inAddedHosts) {
    403  for (let host of inAddedHosts) {
    404    inoutHostList.push(host);
    405  }
    406 }
    407 
    408 function filterForcedInclusions(inHosts, outNotForced, outForced) {
    409  // Apply our filters (based on policy today) to determine which entries
    410  // will be included without being checked (forced); the others will be
    411  // checked using active probing.
    412  for (let host of inHosts) {
    413    if (
    414      host.policy == "google" ||
    415      host.policy == "public-suffix" ||
    416      host.policy == "public-suffix-requested"
    417    ) {
    418      host.forceInclude = true;
    419      host.error = ERROR_NONE;
    420      outForced.push(host);
    421    } else {
    422      outNotForced.push(host);
    423    }
    424  }
    425 }
    426 
    427 function output(statuses) {
    428  dump("INFO: Writing output to " + OUTPUT + "\n");
    429  try {
    430    let file = new FileUtils.File(
    431      PathUtils.join(Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, OUTPUT)
    432    );
    433    let fos = FileUtils.openSafeFileOutputStream(file);
    434    writeTo(HEADER, fos);
    435    writeTo(getExpirationTimeString(), fos);
    436 
    437    writeTo(GPERF_DELIM, fos);
    438 
    439    for (let status of statuses) {
    440      let includeSubdomains = status.includeSubdomains ? 1 : 0;
    441      writeTo(status.name + ", " + includeSubdomains + "\n", fos);
    442    }
    443 
    444    writeTo(GPERF_DELIM, fos);
    445    FileUtils.closeSafeFileOutputStream(fos);
    446    dump("finished writing output file\n");
    447  } catch (e) {
    448    dump("ERROR: problem writing output to '" + OUTPUT + "': " + e + "\n");
    449    throw e;
    450  }
    451 }
    452 
    453 function errorToString(status) {
    454  return status.error == ERROR_MAX_AGE_TOO_LOW
    455    ? status.error + status.maxAge
    456    : status.error;
    457 }
    458 
    459 async function main(args) {
    460  if (args.length != 1) {
    461    throw new Error(
    462      "Usage: getHSTSPreloadList.js <absolute path to current nsSTSPreloadList.inc>"
    463    );
    464  }
    465 
    466  // get the current preload list
    467  let currentHosts = readCurrentList(args[0]);
    468  // delete any hosts we use in tests so we don't actually connect to them
    469  deleteTestHosts(currentHosts);
    470  // disable the current preload list so it won't interfere with requests we make
    471  Services.prefs.setBoolPref(
    472    "network.stricttransportsecurity.preloadlist",
    473    false
    474  );
    475  // download and parse the raw json file from the Chromium source
    476  let rawdata = await download();
    477  // get just the hosts with mode: "force-https"
    478  let hosts = getHosts(rawdata);
    479  // add hosts in the current list to the new list (avoiding duplicates)
    480  combineLists(hosts, currentHosts);
    481 
    482  // Don't contact hosts that are forced to be included anyway
    483  let hostsToContact = [];
    484  let forcedHosts = [];
    485  filterForcedInclusions(hosts, hostsToContact, forcedHosts);
    486 
    487  // Initialize the final status list
    488  let hstsStatuses = [];
    489  // Add the hosts we use in tests
    490  dump("Adding test hosts\n");
    491  insertHosts(hstsStatuses, getTestHosts());
    492  // Add in the hosts that are forced
    493  dump("Adding forced hosts\n");
    494  insertHosts(hstsStatuses, forcedHosts);
    495 
    496  let total = await probeHSTSStatuses(hostsToContact)
    497    .then(function (probedStatuses) {
    498      return hstsStatuses.concat(probedStatuses);
    499    })
    500    .then(function (statuses) {
    501      return statuses.sort(compareHSTSStatus);
    502    })
    503    .then(function (statuses) {
    504      for (let status of statuses) {
    505        // If we've encountered an error for this entry (other than the site not
    506        // sending an HSTS header), be safe and don't remove it from the list
    507        // (given that it was already on the list).
    508        if (
    509          !status.forceInclude &&
    510          status.error != ERROR_NONE &&
    511          status.error != ERROR_NO_HSTS_HEADER &&
    512          status.error != ERROR_MAX_AGE_TOO_LOW &&
    513          status.name in currentHosts
    514        ) {
    515          // dump("INFO: error connecting to or processing " + status.name + " - using previous status on list\n");
    516          status.maxAge = MINIMUM_REQUIRED_MAX_AGE;
    517          status.includeSubdomains = currentHosts[status.name];
    518        }
    519      }
    520      return statuses;
    521    })
    522    .then(function (statuses) {
    523      // Filter out entries we aren't including.
    524      var includedStatuses = statuses.filter(function (status) {
    525        if (status.maxAge < MINIMUM_REQUIRED_MAX_AGE && !status.forceInclude) {
    526          // dump("INFO: " + status.name + " NOT ON the preload list\n");
    527          return false;
    528        }
    529 
    530        // dump("INFO: " + status.name + " ON the preload list (includeSubdomains: " + status.includeSubdomains + ")\n");
    531        if (status.forceInclude && status.error != ERROR_NONE) {
    532          dump(
    533            status.name +
    534              ": " +
    535              errorToString(status) +
    536              " (error ignored - included regardless)\n"
    537          );
    538        }
    539        return true;
    540      });
    541      return includedStatuses;
    542    });
    543 
    544  // Write the output file
    545  output(total);
    546 
    547  dump("HSTS probing all done\n");
    548 }
    549 
    550 // arguments is a global within xpcshell
    551 spinResolve(main(arguments));