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));