NetworkErrorLogging.sys.mjs (13957B)
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 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 function policyExpired(policy) { 6 let currentDate = new Date(); 7 return (currentDate - policy.creation) / 1_000 > policy.nel.max_age; 8 } 9 10 function errorType(aChannel) { 11 // TODO: we have to map a lot more error codes 12 switch (aChannel.status) { 13 case Cr.NS_ERROR_UNKNOWN_HOST: 14 // TODO: if there is no connectivity, return "dns.unreachable" 15 return "dns.name_not_resolved"; 16 case Cr.NS_ERROR_REDIRECT_LOOP: 17 return "http.response.redirect_loop"; 18 case Cr.NS_BINDING_REDIRECTED: 19 return "ok"; 20 case Cr.NS_ERROR_NET_TIMEOUT: 21 return "tcp.timed_out"; 22 case Cr.NS_ERROR_NET_RESET: 23 return "tcp.reset"; 24 case Cr.NS_ERROR_CONNECTION_REFUSED: 25 return "tcp.refused"; 26 default: 27 break; 28 } 29 30 if ( 31 aChannel.status == Cr.NS_OK && 32 (aChannel.responseStatus / 100 == 2 || aChannel.responseStatus == 304) 33 ) { 34 return "ok"; 35 } 36 37 if ( 38 aChannel.status == Cr.NS_OK && 39 aChannel.responseStatus >= 400 && 40 aChannel.responseStatus <= 599 41 ) { 42 return "http.error"; 43 } 44 return "unknown" + aChannel.status; 45 } 46 47 function channelPhase(aChannel) { 48 const NS_NET_STATUS_RESOLVING_HOST = 0x4b0003; 49 const NS_NET_STATUS_RESOLVED_HOST = 0x4b000b; 50 const NS_NET_STATUS_CONNECTING_TO = 0x4b0007; 51 const NS_NET_STATUS_CONNECTED_TO = 0x4b0004; 52 const NS_NET_STATUS_TLS_HANDSHAKE_STARTING = 0x4b000c; 53 const NS_NET_STATUS_TLS_HANDSHAKE_ENDED = 0x4b000d; 54 const NS_NET_STATUS_SENDING_TO = 0x4b0005; 55 const NS_NET_STATUS_WAITING_FOR = 0x4b000a; 56 const NS_NET_STATUS_RECEIVING_FROM = 0x4b0006; 57 const NS_NET_STATUS_READING = 0x4b0008; 58 const NS_NET_STATUS_WRITING = 0x4b0009; 59 60 let lastStatus = aChannel.QueryInterface( 61 Ci.nsIHttpChannelInternal 62 ).lastTransportStatus; 63 64 switch (lastStatus) { 65 case NS_NET_STATUS_RESOLVING_HOST: 66 case NS_NET_STATUS_RESOLVED_HOST: 67 return "dns"; 68 case NS_NET_STATUS_CONNECTING_TO: 69 case NS_NET_STATUS_CONNECTED_TO: // TODO: is this right? 70 return "connection"; 71 case NS_NET_STATUS_TLS_HANDSHAKE_STARTING: 72 case NS_NET_STATUS_TLS_HANDSHAKE_ENDED: 73 return "connection"; 74 case NS_NET_STATUS_SENDING_TO: 75 case NS_NET_STATUS_WAITING_FOR: 76 case NS_NET_STATUS_RECEIVING_FROM: 77 case NS_NET_STATUS_READING: 78 case NS_NET_STATUS_WRITING: 79 return "application"; 80 default: 81 // XXX(valentin): we default to DNS, but we should never get here. 82 return "dns"; 83 } 84 } 85 86 export class NetworkErrorLogging { 87 constructor() {} 88 89 // Policy cache 90 // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#policy-cache 91 policyCache = {}; 92 // TODO: maybe persist policies to disk? 93 94 // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#process-policy-headers 95 registerPolicy(aChannel) { 96 // 1. Abort these steps if any of the following conditions are true: 97 // 1.1 The result of executing the "Is origin potentially trustworthy?" algorithm on request's origin is not Potentially Trustworthy. 98 if ( 99 !Services.scriptSecurityManager.getChannelResultPrincipal(aChannel) 100 .isOriginPotentiallyTrustworthy 101 ) { 102 return; 103 } 104 105 // 4. Let header be the value of the response header whose name is NEL. 106 // 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. 107 let list = []; 108 aChannel.getOriginalResponseHeader("NEL", { 109 QueryInterface: ChromeUtils.generateQI(["nsIHttpHeaderVisitor"]), 110 visitHeader: (aHeader, aValue) => { 111 list.push(aValue); 112 // We only care about the first one so we could exit early 113 // We could throw early, but that makes the errors show up in stderr. 114 // The performance impact of not throwing is minimal. 115 // throw new Error(Cr.NS_ERROR_ABORT); 116 }, 117 }); 118 119 // 1.2 response does not contain a response header whose name is NEL. 120 if (!list.length) { 121 return; 122 } 123 124 // 2. Let origin be request's origin. 125 let origin = 126 Services.scriptSecurityManager.getChannelResultPrincipal(aChannel).origin; 127 128 // 3. Let key be the result of calling determine the network partition key, given request. 129 let key = Services.io.originAttributesForNetworkState(aChannel); 130 131 // 6. Let item be the first element of list. 132 let item = JSON.parse(list[0]); 133 134 // 7. If item has no member named max_age, or that member's value is not a number, abort these steps. 135 if (!item.max_age || !Number.isInteger(item.max_age)) { 136 return; 137 } 138 139 // 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. 140 if (!item.max_age) { 141 delete this.policyCache[String([key, origin])]; 142 return; 143 } 144 145 // 9. If item has no member named report_to, or that member's value is not a string, abort these steps. 146 if (!item.report_to || typeof item.report_to != "string") { 147 return; 148 } 149 150 // 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. 151 if ( 152 item.success_fraction && 153 (typeof item.success_fraction != "number" || 154 item.success_fraction < 0 || 155 item.success_fraction > 1) 156 ) { 157 return; 158 } 159 160 // 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. 161 if ( 162 item.failure_fraction && 163 (typeof item.failure_fraction != "number" || 164 item.failure_fraction < 0 || 165 item.success_fraction > 1) 166 ) { 167 return; 168 } 169 170 // 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. 171 if ( 172 item.request_headers && 173 !Array.isArray( 174 item.request_headers || 175 !item.request_headers.every(e => typeof e == "string") 176 ) 177 ) { 178 return; 179 } 180 181 // 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. 182 if ( 183 item.response_headers && 184 !Array.isArray( 185 item.response_headers || 186 !item.response_headers.every(e => typeof e == "string") 187 ) 188 ) { 189 return; 190 } 191 192 // 14. Let policy be a new NEL policy whose properties are set as follows: 193 let policy = {}; 194 195 // received IP address 196 // XXX: What should we do when using a proxy? 197 try { 198 policy.ip_address = aChannel.QueryInterface( 199 Ci.nsIHttpChannelInternal 200 ).remoteAddress; 201 } catch (e) { 202 return; 203 } 204 205 // origin 206 policy.origin = origin; 207 208 if (item.include_subdomains) { 209 policy.subdomains = true; 210 } 211 212 policy.request_headers = item.request_headers; 213 policy.response_headers = item.response_headers; 214 policy.ttl = item.max_age; 215 policy.creation = new Date(); 216 policy.successful_sampling_rate = item.success_fraction || 0.0; 217 policy.failure_sampling_rate = item.failure_fraction || 1.0; 218 219 policy.nel = item; 220 221 // 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). 222 this.policyCache[String([key, origin])] = policy; 223 } 224 225 // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#choose-a-policy-for-a-request 226 choosePolicyForRequest(aChannel) { 227 // 1. Let origin be request's origin. 228 let principal = 229 Services.scriptSecurityManager.getChannelResultPrincipal(aChannel); 230 let origin = principal.origin; 231 // 2. Let key be the result of calling determine the network partition key, given request. 232 let key = Services.io.originAttributesForNetworkState(aChannel); 233 234 // 3. If there is an entry in the policy cache for (key, origin): 235 let policy = this.policyCache[String([key, origin])]; 236 // 3.1. Let policy be that entry. 237 if (policy) { 238 // 3.2. If policy is not expired, return it. 239 if (!policyExpired(policy)) { 240 return { policy, key, origin }; 241 } 242 } 243 244 // 4. For each parent origin that is a superdomain match of origin: 245 // 4.1. If there is an entry in the policy cache for (key, parent origin): 246 // 4.1.1. Let policy be that entry. 247 // 4.1.2. If policy is not expired, and its subdomains flag is include, return it. 248 while (principal.nextSubDomainPrincipal) { 249 principal = principal.nextSubDomainPrincipal; 250 origin = principal.origin; 251 policy = this.policyCache[String([key, origin])]; 252 if (policy && !policyExpired(policy)) { 253 return { policy, key, origin }; 254 } 255 } 256 257 // 5. Return no policy. 258 return {}; 259 } 260 261 // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#generate-a-network-error-report 262 generateNELReport(aChannel) { 263 // 1. If the result of executing the "Is origin potentially trustworthy?" algorithm on request's origin is not Potentially Trustworthy, return null. 264 if ( 265 !Services.scriptSecurityManager.getChannelResultPrincipal(aChannel) 266 .isOriginPotentiallyTrustworthy 267 ) { 268 return null; 269 } 270 // 2. Let origin be request's origin. 271 let origin = 272 Services.scriptSecurityManager.getChannelResultPrincipal(aChannel).origin; 273 274 // 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. 275 let { 276 policy, 277 key, 278 origin: policyOrigin, 279 } = this.choosePolicyForRequest(aChannel); 280 if (!policy) { 281 return null; 282 } 283 284 // 4. Determine the active sampling rate for this request: 285 let samplingRate = 0.0; 286 if ( 287 aChannel.status == Cr.NS_OK && 288 aChannel.responseStatus >= 200 && 289 aChannel.responseStatus <= 299 290 ) { 291 // If request succeeded, let sampling rate be policy's successful sampling rate. 292 samplingRate = policy.successful_sampling_rate || 0.0; 293 } else { 294 // If request failed, let sampling rate be policy's failure sampling rate. 295 samplingRate = policy.successful_sampling_rate || 1.0; 296 } 297 298 // 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. 299 if (Math.random() >= samplingRate) { 300 return null; 301 } 302 303 // 6. Let report body be a new ECMAScript object with the following properties: 304 305 let phase = channelPhase(aChannel); 306 let report_body = { 307 sampling_fraction: samplingRate, 308 elapsed_time: 1, // TODO 309 phase, 310 type: errorType(aChannel), // TODO 311 }; 312 313 // 7. If report body's phase property is not dns, append the following properties to report body: 314 if (phase != "dns") { 315 // XXX: should we actually report server_ip? 316 // It could be used to detect the presence of a PiHole. 317 report_body.server_ip = aChannel.QueryInterface( 318 Ci.nsIHttpChannelInternal 319 ).remoteAddress; 320 report_body.protocol = aChannel.protocolVersion; 321 } 322 323 // 8. If report body's phase property is not dns or connection, append the following properties to report body: 324 // referrer? 325 // method 326 // request_headers? 327 // response_headers? 328 // status_code 329 if (phase != "dns" && phase != "connection") { 330 report_body.method = aChannel.requestMethod; 331 report_body.status_code = aChannel.responseStatus; 332 } 333 334 // 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. 335 if ( 336 origin != policyOrigin && 337 policy.subdomains && 338 report_body.phase != "dns" 339 ) { 340 return null; 341 } 342 343 // 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: 344 if (phase != "dns" && report_body.server_ip != policy.ip_address) { 345 // 10.1 Set report body's phase to dns. 346 report_body.phase = "dns"; 347 // 10.2 Set report body's type to dns.address_changed. 348 report_body.type = "dns.address_changed"; 349 // 10.3 Clear report body's request_headers, response_headers, status_code, and elapsed_time properties. 350 delete report_body.request_headers; 351 delete report_body.response_headers; 352 delete report_body.status_code; 353 delete report_body.elapsed_time; 354 } 355 356 // 11. If policy is stale, then delete policy from the policy cache. 357 let currentDate = new Date(); 358 if ((currentDate - policy.creation) / 1_000 > 172800) { 359 // Delete the policy. 360 delete this.policyCache[String([key, policyOrigin])]; 361 362 // XXX: should we exit here, or continue submit the report? 363 } 364 365 // 12. Return report body and policy. 366 367 // https://www.w3.org/TR/2023/WD-network-error-logging-20231005/#deliver-a-network-report 368 // 1. Let url be request's URL. 369 // 2. Clear url's fragment. 370 let uriMutator = aChannel.URI.mutate().setRef(""); 371 // 3. If report body's phase property is dns or connection: 372 // Clear url's path and query. 373 if (report_body.phase == "dns" || report_body.phase == "connection") { 374 uriMutator.setPathQueryRef(""); 375 } 376 if (aChannel.URI.hasUserPass) { 377 uriMutator.setUserPass(""); 378 } 379 let url = uriMutator.finalize().spec; 380 381 // 4. Generate a network report given these parameters: 382 // nsINetworkErrorReport 383 let retObj = { 384 body: JSON.stringify(report_body), 385 group: policy.nel.report_to, 386 url, 387 }; 388 389 // nsHttpChannel will call ReportDeliver::Fetch 390 return retObj; 391 } 392 393 QueryInterface = ChromeUtils.generateQI(["nsINetworkErrorLogging"]); 394 }