/* 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 https://mozilla.org/MPL/2.0/. */ function policyExpired(policy) { let currentDate = new Date(); return (currentDate - policy.creation) / 1_000 > policy.nel.max_age; } function errorType(aChannel) { // TODO: we have to map a lot more error codes switch (aChannel.status) { case Cr.NS_ERROR_UNKNOWN_HOST: // TODO: if there is no connectivity, return "dns.unreachable" return "dns.name_not_resolved"; case Cr.NS_ERROR_REDIRECT_LOOP: return "http.response.redirect_loop"; case Cr.NS_BINDING_REDIRECTED: return "ok"; case Cr.NS_ERROR_NET_TIMEOUT: return "tcp.timed_out"; case Cr.NS_ERROR_NET_RESET: return "tcp.reset"; case Cr.NS_ERROR_CONNECTION_REFUSED: return "tcp.refused"; default: break; } if ( aChannel.status == Cr.NS_OK && (aChannel.responseStatus / 100 == 2 || aChannel.responseStatus == 304) ) { return "ok"; } if ( aChannel.status == Cr.NS_OK && aChannel.responseStatus >= 400 && aChannel.responseStatus <= 599 ) { return "http.error"; } return "unknown" + aChannel.status; } function channelPhase(aChannel) { const NS_NET_STATUS_RESOLVING_HOST = 0x4b0003; const NS_NET_STATUS_RESOLVED_HOST = 0x4b000b; const NS_NET_STATUS_CONNECTING_TO = 0x4b0007; const NS_NET_STATUS_CONNECTED_TO = 0x4b0004; const NS_NET_STATUS_TLS_HANDSHAKE_STARTING = 0x4b000c; const NS_NET_STATUS_TLS_HANDSHAKE_ENDED = 0x4b000d; const NS_NET_STATUS_SENDING_TO = 0x4b0005; const NS_NET_STATUS_WAITING_FOR = 0x4b000a; const NS_NET_STATUS_RECEIVING_FROM = 0x4b0006; const NS_NET_STATUS_READING = 0x4b0008; const NS_NET_STATUS_WRITING = 0x4b0009; let lastStatus = aChannel.QueryInterface( Ci.nsIHttpChannelInternal ).lastTransportStatus; switch (lastStatus) { case NS_NET_STATUS_RESOLVING_HOST: case NS_NET_STATUS_RESOLVED_HOST: return "dns"; case NS_NET_STATUS_CONNECTING_TO: case NS_NET_STATUS_CONNECTED_TO: // TODO: is this right? return "connection"; case NS_NET_STATUS_TLS_HANDSHAKE_STARTING: case NS_NET_STATUS_TLS_HANDSHAKE_ENDED: return "connection"; case NS_NET_STATUS_SENDING_TO: case NS_NET_STATUS_WAITING_FOR: case NS_NET_STATUS_RECEIVING_FROM: case NS_NET_STATUS_READING: case NS_NET_STATUS_WRITING: return "application"; default: // XXX(valentin): we default to DNS, but we should never get here. return "dns"; } } export class NetworkErrorLogging { constructor() {} // Policy cache // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#policy-cache policyCache = {}; // TODO: maybe persist policies to disk? // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#process-policy-headers registerPolicy(aChannel) { // 1. Abort these steps if any of the following conditions are true: // 1.1 The result of executing the "Is origin potentially trustworthy?" algorithm on request's origin is not Potentially Trustworthy. if ( !Services.scriptSecurityManager.getChannelResultPrincipal(aChannel) .isOriginPotentiallyTrustworthy ) { return; } // 4. Let header be the value of the response header whose name is NEL. // 5. Let list be the result of executing the algorithm defined in Section 4 of [HTTP-JFV] on header. If that algorithm results in an error, or if list is empty, abort these steps. let list = []; aChannel.getOriginalResponseHeader("NEL", { QueryInterface: ChromeUtils.generateQI(["nsIHttpHeaderVisitor"]), visitHeader: (aHeader, aValue) => { list.push(aValue); // We only care about the first one so we could exit early // We could throw early, but that makes the errors show up in stderr. // The performance impact of not throwing is minimal. // throw new Error(Cr.NS_ERROR_ABORT); }, }); // 1.2 response does not contain a response header whose name is NEL. if (!list.length) { return; } // 2. Let origin be request's origin. let origin = Services.scriptSecurityManager.getChannelResultPrincipal(aChannel).origin; // 3. Let key be the result of calling determine the network partition key, given request. let key = Services.io.originAttributesForNetworkState(aChannel); // 6. Let item be the first element of list. let item = JSON.parse(list[0]); // 7. If item has no member named max_age, or that member's value is not a number, abort these steps. if (!item.max_age || !Number.isInteger(item.max_age)) { return; } // 8. If the value of item's max_age member is 0, then remove any NEL policy from the policy cache whose origin is origin, and skip the remaining steps. if (!item.max_age) { delete this.policyCache[String([key, origin])]; return; } // 9. If item has no member named report_to, or that member's value is not a string, abort these steps. if (!item.report_to || typeof item.report_to != "string") { return; } // 10. If item has a member named success_fraction, whose value is not a number in the range 0.0 to 1.0, inclusive, abort these steps. if ( item.success_fraction && (typeof item.success_fraction != "number" || item.success_fraction < 0 || item.success_fraction > 1) ) { return; } // 11. If item has a member named failure_fraction, whose value is not a number in the range 0.0 to 1.0, inclusive, abort these steps. if ( item.failure_fraction && (typeof item.failure_fraction != "number" || item.failure_fraction < 0 || item.success_fraction > 1) ) { return; } // 12. If item has a member named request_headers, whose value is not a list, or if any element of that list is not a string, abort these steps. if ( item.request_headers && !Array.isArray( item.request_headers || !item.request_headers.every(e => typeof e == "string") ) ) { return; } // 13. If item has a member named response_headers, whose value is not a list, or if any element of that list is not a string, abort these steps. if ( item.response_headers && !Array.isArray( item.response_headers || !item.response_headers.every(e => typeof e == "string") ) ) { return; } // 14. Let policy be a new NEL policy whose properties are set as follows: let policy = {}; // received IP address // XXX: What should we do when using a proxy? try { policy.ip_address = aChannel.QueryInterface( Ci.nsIHttpChannelInternal ).remoteAddress; } catch (e) { return; } // origin policy.origin = origin; if (item.include_subdomains) { policy.subdomains = true; } policy.request_headers = item.request_headers; policy.response_headers = item.response_headers; policy.ttl = item.max_age; policy.creation = new Date(); policy.successful_sampling_rate = item.success_fraction || 0.0; policy.failure_sampling_rate = item.failure_fraction || 1.0; // TODO: Remove these when no longer needed policy.nel = item; let reportTo = JSON.parse( aChannel.QueryInterface(Ci.nsIHttpChannel).getResponseHeader("Report-To") ); policy.reportTo = reportTo; // 15. If there is already an entry in the policy cache for (key, origin), replace it with policy; otherwise, insert policy into the policy cache for (key, origin). this.policyCache[String([key, origin])] = policy; } // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#choose-a-policy-for-a-request choosePolicyForRequest(aChannel) { // 1. Let origin be request's origin. let principal = Services.scriptSecurityManager.getChannelResultPrincipal(aChannel); let origin = principal.origin; // 2. Let key be the result of calling determine the network partition key, given request. let key = Services.io.originAttributesForNetworkState(aChannel); // 3. If there is an entry in the policy cache for (key, origin): let policy = this.policyCache[String([key, origin])]; // 3.1. Let policy be that entry. if (policy) { // 3.2. If policy is not expired, return it. if (!policyExpired(policy)) { return { policy, key, origin }; } } // 4. For each parent origin that is a superdomain match of origin: // 4.1. If there is an entry in the policy cache for (key, parent origin): // 4.1.1. Let policy be that entry. // 4.1.2. If policy is not expired, and its subdomains flag is include, return it. while (principal.nextSubDomainPrincipal) { principal = principal.nextSubDomainPrincipal; origin = principal.origin; policy = this.policyCache[String([key, origin])]; if (policy && !policyExpired(policy)) { return { policy, key, origin }; } } // 5. Return no policy. return {}; } // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#generate-a-network-error-report generateNELReport(aChannel) { // 1. If the result of executing the "Is origin potentially trustworthy?" algorithm on request's origin is not Potentially Trustworthy, return null. if ( !Services.scriptSecurityManager.getChannelResultPrincipal(aChannel) .isOriginPotentiallyTrustworthy ) { return; } // 2. Let origin be request's origin. let origin = Services.scriptSecurityManager.getChannelResultPrincipal(aChannel).origin; // 3. Let policy be the result of executing 5.1 Choose a policy for a request on request. If policy is no policy, return null. let { policy, key, origin: policyOrigin, } = this.choosePolicyForRequest(aChannel); if (!policy) { return; } // 4. Determine the active sampling rate for this request: let samplingRate = 0.0; if ( aChannel.status == Cr.NS_OK && aChannel.responseStatus >= 200 && aChannel.responseStatus <= 299 ) { // If request succeeded, let sampling rate be policy's successful sampling rate. samplingRate = policy.successful_sampling_rate || 0.0; } else { // If request failed, let sampling rate be policy's failure sampling rate. samplingRate = policy.successful_sampling_rate || 1.0; } // 5. Decide whether or not to report on this request. Let roll be a random number between 0.0 and 1.0, inclusive. If roll ≥ sampling rate, return null. if (Math.random() >= samplingRate) { return; } // 6. Let report body be a new ECMAScript object with the following properties: let phase = channelPhase(aChannel); let report_body = { sampling_fraction: samplingRate, elapsed_time: 1, // TODO phase, type: errorType(aChannel), // TODO }; // 7. If report body's phase property is not dns, append the following properties to report body: if (phase != "dns") { // XXX: should we actually report server_ip? // It could be used to detect the presence of a PiHole. report_body.server_ip = aChannel.QueryInterface( Ci.nsIHttpChannelInternal ).remoteAddress; report_body.protocol = aChannel.protocolVersion; } // 8. If report body's phase property is not dns or connection, append the following properties to report body: // referrer? // method // request_headers? // response_headers? // status_code if (phase != "dns" && phase != "connection") { report_body.method = aChannel.requestMethod; report_body.status_code = aChannel.responseStatus; } // 9. If origin is not equal to policy's origin, policy's subdomains flag is include, and report body's phase property is not dns, return null. if ( origin != policyOrigin && policy.subdomains && report_body.phase != "dns" ) { return; } // 10. If report body's phase property is not dns, and report body's server_ip property is non-empty and not equal to policy's received IP address: if (phase != "dns" && report_body.server_ip != policy.ip_address) { // 10.1 Set report body's phase to dns. report_body.phase = "dns"; // 10.2 Set report body's type to dns.address_changed. report_body.type = "dns.address_changed"; // 10.3 Clear report body's request_headers, response_headers, status_code, and elapsed_time properties. delete report_body.request_headers; delete report_body.response_headers; delete report_body.status_code; delete report_body.elapsed_time; } // 11. If policy is stale, then delete policy from the policy cache. let currentDate = new Date(); if ((currentDate - policy.creation) / 1_000 > 172800) { // Delete the policy. delete this.policyCache[String([key, policyOrigin])]; // XXX: should we exit here, or continue submit the report? } // 12. Return report body and policy. // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#deliver-a-network-report // 1. Let url be request's URL. // 2. Clear url's fragment. let uriMutator = aChannel.URI.mutate().setRef(""); // 3. If report body's phase property is dns or connection: // Clear url's path and query. if (report_body.phase == "dns" || report_body.phase == "connection") { uriMutator.setPathQueryRef(""); } // 4. Generate a network report given these parameters: let report = { type: "network-error", url: uriMutator.finalize().spec, user_agent: Cc["@mozilla.org/network/protocol;1?name=http"].getService( Ci.nsIHttpProtocolHandler ).userAgent, body: report_body, }; // XXX: this would benefit from using the actual reporting API, // but it's not clear how easy it is to: // - use it in the parent process // - have it use the Report-To header // https://w3c.github.io/reporting/#queue-report if (policy && policy.reportTo.group === policy.nel.report_to) { // TODO: defer to later. fetch(policy.reportTo.endpoints[0].url, { method: "POST", mode: "cors", credentials: "omit", headers: { "Content-Type": "application/reports+json", }, body: JSON.stringify([report]), triggeringPrincipal: Services.scriptSecurityManager.getChannelResultPrincipal(aChannel), }); } } QueryInterface = ChromeUtils.generateQI(["nsINetworkErrorLogging"]); }