diff options
Diffstat (limited to 'toolkit/content/aboutNetError.mjs')
-rw-r--r-- | toolkit/content/aboutNetError.mjs | 1556 |
1 files changed, 1556 insertions, 0 deletions
diff --git a/toolkit/content/aboutNetError.mjs b/toolkit/content/aboutNetError.mjs new file mode 100644 index 0000000000..b458a8ccab --- /dev/null +++ b/toolkit/content/aboutNetError.mjs @@ -0,0 +1,1556 @@ +/* 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/. */ + +/* eslint-env mozilla/remote-page */ +/* eslint-disable import/no-unassigned-import */ + +import { + parse, + pemToDER, +} from "chrome://global/content/certviewer/certDecoder.mjs"; + +const formatter = new Intl.DateTimeFormat(); + +const HOST_NAME = getHostName(); + +function getHostName() { + try { + return new URL(RPMGetInnerMostURI(document.location.href)).hostname; + } catch (error) { + console.error("Could not parse URL", error); + } + return ""; +} + +// Used to check if we have a specific localized message for an error. +const KNOWN_ERROR_TITLE_IDS = new Set([ + // Error titles: + "connectionFailure-title", + "deniedPortAccess-title", + "dnsNotFound-title", + "dns-not-found-trr-only-title2", + "fileNotFound-title", + "fileAccessDenied-title", + "generic-title", + "captivePortal-title", + "malformedURI-title", + "netInterrupt-title", + "notCached-title", + "netOffline-title", + "contentEncodingError-title", + "unsafeContentType-title", + "netReset-title", + "netTimeout-title", + "unknownProtocolFound-title", + "proxyConnectFailure-title", + "proxyResolveFailure-title", + "redirectLoop-title", + "unknownSocketType-title", + "nssFailure2-title", + "csp-xfo-error-title", + "corruptedContentError-title", + "sslv3Used-title", + "inadequateSecurityError-title", + "blockedByPolicy-title", + "clockSkewError-title", + "networkProtocolError-title", + "nssBadCert-title", + "nssBadCert-sts-title", + "certerror-mitm-title", +]); + +/* The error message IDs from nsserror.ftl get processed into + * aboutNetErrorCodes.js which is loaded before we are: */ +/* global KNOWN_ERROR_MESSAGE_IDS */ +const ERROR_MESSAGES_FTL = "toolkit/neterror/nsserrors.ftl"; + +// The following parameters are parsed from the error URL: +// e - the error code +// s - custom CSS class to allow alternate styling/favicons +// d - error description +// captive - "true" to indicate we're behind a captive portal. +// Any other value is ignored. + +// Note that this file uses document.documentURI to get +// the URL (with the format from above). This is because +// document.location.href gets the current URI off the docshell, +// which is the URL displayed in the location bar, i.e. +// the URI that the user attempted to load. + +let searchParams = new URLSearchParams(document.documentURI.split("?")[1]); + +let gErrorCode = searchParams.get("e"); +let gIsCertError = gErrorCode == "nssBadCert"; +let gHasSts = gIsCertError && getCSSClass() === "badStsCert"; + +// If the location of the favicon changes, FAVICON_CERTERRORPAGE_URL and/or +// FAVICON_ERRORPAGE_URL in toolkit/components/places/nsFaviconService.idl +// should also be updated. +document.getElementById("favicon").href = + gIsCertError || gErrorCode == "nssFailure2" + ? "chrome://global/skin/icons/warning.svg" + : "chrome://global/skin/icons/info.svg"; + +function getCSSClass() { + return searchParams.get("s"); +} + +function getDescription() { + return searchParams.get("d"); +} + +function isCaptive() { + return searchParams.get("captive") == "true"; +} + +/** + * We don't actually know what the MitM is called (since we don't + * maintain a list), so we'll try and display the common name of the + * root issuer to the user. In the worst case they are as clueless as + * before, in the best case this gives them an actionable hint. + * This may be revised in the future. + */ +function getMitmName(failedCertInfo) { + return failedCertInfo.issuerCommonName; +} + +function retryThis(buttonEl) { + RPMSendAsyncMessage("Browser:EnableOnlineMode"); + buttonEl.disabled = true; +} + +function showPrefChangeContainer() { + const panel = document.getElementById("prefChangeContainer"); + panel.hidden = false; + document.getElementById("netErrorButtonContainer").hidden = true; + document + .getElementById("prefResetButton") + .addEventListener("click", function resetPreferences() { + RPMSendAsyncMessage("Browser:ResetSSLPreferences"); + }); + setFocus("#prefResetButton", "beforeend"); +} + +function toggleCertErrorDebugInfoVisibility(shouldShow) { + let debugInfo = document.getElementById("certificateErrorDebugInformation"); + let copyButton = document.getElementById("copyToClipboardTop"); + + if (shouldShow === undefined) { + shouldShow = debugInfo.hidden; + } + debugInfo.hidden = !shouldShow; + if (shouldShow) { + copyButton.scrollIntoView({ block: "start", behavior: "smooth" }); + copyButton.focus(); + } +} + +function setupAdvancedButton() { + // Get the hostname and add it to the panel + var panel = document.getElementById("badCertAdvancedPanel"); + + // Register click handler for the weakCryptoAdvancedPanel + document + .getElementById("advancedButton") + .addEventListener("click", togglePanelVisibility); + + function togglePanelVisibility() { + if (panel.hidden) { + // Reveal + revealAdvancedPanelSlowlyAsync(); + + // send event to trigger telemetry ping + document.dispatchEvent( + new CustomEvent("AboutNetErrorUIExpanded", { bubbles: true }) + ); + } else { + // Hide + panel.hidden = true; + } + } + + if (getCSSClass() == "expertBadCert") { + revealAdvancedPanelSlowlyAsync(); + } +} + +async function revealAdvancedPanelSlowlyAsync() { + const badCertAdvancedPanel = document.getElementById("badCertAdvancedPanel"); + const exceptionDialogButton = document.getElementById( + "exceptionDialogButton" + ); + + // Toggling the advanced panel must ensure that the debugging + // information panel is hidden as well, since it's opened by the + // error code link in the advanced panel. + toggleCertErrorDebugInfoVisibility(false); + + // Reveal, but disabled (and grayed-out) for 3.0s. + badCertAdvancedPanel.hidden = false; + exceptionDialogButton.disabled = true; + + // - + + if (exceptionDialogButton.resetReveal) { + exceptionDialogButton.resetReveal(); // Reset if previous is pending. + } + let wasReset = false; + exceptionDialogButton.resetReveal = () => { + wasReset = true; + }; + + // Wait for 10 frames to ensure that the warning text is rendered + // and gets all the way to the screen for the user to read it. + // This is only ~0.160s at 60Hz, so it's not too much extra time that we're + // taking to ensure that we're caught up with rendering, on top of the + // (by default) whole second(s) we're going to wait based on the + // security.dialog_enable_delay pref. + // The catching-up to rendering is the important part, not the + // N-frame-delay here. + for (let i = 0; i < 10; i++) { + await new Promise(requestAnimationFrame); + } + + // Wait another Nms (default: 1000) for the user to be very sure. (Sorry speed readers!) + const securityDelayMs = RPMGetIntPref("security.dialog_enable_delay", 1000); + await new Promise(go => setTimeout(go, securityDelayMs)); + + if (wasReset) { + return; + } + + // Enable and un-gray-out. + exceptionDialogButton.disabled = false; +} + +function disallowCertOverridesIfNeeded() { + // Disallow overrides if this is a Strict-Transport-Security + // host and the cert is bad (STS Spec section 7.3) or if the + // certerror is in a frame (bug 633691). + if (gHasSts || window != top) { + document.getElementById("exceptionDialogButton").hidden = true; + } + if (gHasSts) { + const stsExplanation = document.getElementById("badStsCertExplanation"); + document.l10n.setAttributes( + stsExplanation, + "certerror-what-should-i-do-bad-sts-cert-explanation", + { hostname: HOST_NAME } + ); + stsExplanation.hidden = false; + + document.l10n.setAttributes( + document.getElementById("returnButton"), + "neterror-return-to-previous-page-button" + ); + document.l10n.setAttributes( + document.getElementById("advancedPanelReturnButton"), + "neterror-return-to-previous-page-button" + ); + } +} + +function recordTRREventTelemetry( + warningPageType, + trrMode, + trrDomain, + skipReason +) { + RPMRecordTelemetryEvent( + "security.doh.neterror", + "load", + "dohwarning", + warningPageType, + { + mode: trrMode, + provider_key: trrDomain, + skip_reason: skipReason, + } + ); + + const netErrorButtonDiv = document.getElementById("netErrorButtonContainer"); + const buttons = netErrorButtonDiv.querySelectorAll("button"); + for (let b of buttons) { + b.addEventListener("click", function (e) { + let target = e.originalTarget; + let telemetryId = target.dataset.telemetryId; + RPMRecordTelemetryEvent( + "security.doh.neterror", + "click", + telemetryId, + warningPageType, + { + mode: trrMode, + provider_key: trrDomain, + skip_reason: skipReason, + } + ); + }); + } +} + +function initPage() { + // We show an offline support page in case of a system-wide error, + // when a user cannot connect to the internet and access the SUMO website. + // For example, clock error, which causes certerrors across the web or + // a security software conflict where the user is unable to connect + // to the internet. + // The URL that prompts us to show an offline support page should have the following + // format: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/supportPageSlug", + // so we can extract the support page slug. + let baseURL = RPMGetFormatURLPref("app.support.baseURL"); + if (document.location.href.startsWith(baseURL)) { + let supportPageSlug = document.location.pathname.split("/").pop(); + RPMSendAsyncMessage("DisplayOfflineSupportPage", { + supportPageSlug, + }); + } + + const className = getCSSClass(); + if (className) { + document.body.classList.add(className); + } + + const isTRROnlyFailure = gErrorCode == "dnsNotFound" && RPMIsTRROnlyFailure(); + + let isNativeFallbackWarning = false; + if (RPMGetBoolPref("network.trr.display_fallback_warning")) { + isNativeFallbackWarning = + gErrorCode == "dnsNotFound" && RPMIsNativeFallbackFailure(); + } + + const docTitle = document.querySelector("title"); + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + + if (gIsCertError) { + const isStsError = window !== window.top || gHasSts; + const errArgs = { hostname: HOST_NAME }; + if (isCaptive()) { + document.l10n.setAttributes( + docTitle, + "neterror-captive-portal-page-title" + ); + document.l10n.setAttributes(bodyTitle, "captivePortal-title"); + document.l10n.setAttributes( + shortDesc, + "neterror-captive-portal", + errArgs + ); + initPageCaptivePortal(); + } else { + if (isStsError) { + document.l10n.setAttributes(docTitle, "certerror-sts-page-title"); + document.l10n.setAttributes(bodyTitle, "nssBadCert-sts-title"); + document.l10n.setAttributes(shortDesc, "certerror-sts-intro", errArgs); + } else { + document.l10n.setAttributes(docTitle, "certerror-page-title"); + document.l10n.setAttributes(bodyTitle, "nssBadCert-title"); + document.l10n.setAttributes(shortDesc, "certerror-intro", errArgs); + } + initPageCertError(); + } + + initCertErrorPageActions(); + setTechnicalDetailsOnCertError(); + return; + } + + document.body.classList.add("neterror"); + + let longDesc = document.getElementById("errorLongDesc"); + const tryAgain = document.getElementById("netErrorButtonContainer"); + tryAgain.hidden = false; + const learnMore = document.getElementById("learnMoreContainer"); + const learnMoreLink = document.getElementById("learnMoreLink"); + learnMoreLink.setAttribute("href", baseURL + "connection-not-secure"); + + let pageTitleId = "neterror-page-title"; + let bodyTitleId = gErrorCode + "-title"; + + switch (gErrorCode) { + case "blockedByPolicy": + pageTitleId = "neterror-blocked-by-policy-page-title"; + document.body.classList.add("blocked"); + + // Remove the "Try again" button from pages that don't need it. + // For pages blocked by policy, trying again won't help. + tryAgain.hidden = true; + break; + + case "cspBlocked": + case "xfoBlocked": { + bodyTitleId = "csp-xfo-error-title"; + + // Remove the "Try again" button for XFO and CSP violations, + // since it's almost certainly useless. (Bug 553180) + tryAgain.hidden = true; + + // Adding a button for opening websites blocked for CSP and XFO violations + // in a new window. (Bug 1461195) + document.getElementById("errorShortDesc").hidden = true; + + document.l10n.setAttributes(longDesc, "csp-xfo-blocked-long-desc", { + hostname: HOST_NAME, + }); + longDesc = null; + + document.getElementById("openInNewWindowContainer").hidden = false; + + const openInNewWindowButton = document.getElementById( + "openInNewWindowButton" + ); + openInNewWindowButton.href = document.location.href; + + // Add a learn more link + learnMore.hidden = false; + learnMoreLink.setAttribute("href", baseURL + "xframe-neterror-page"); + + setupBlockingReportingUI(); + break; + } + + case "dnsNotFound": + pageTitleId = "neterror-dns-not-found-title"; + if (!isTRROnlyFailure) { + RPMCheckAlternateHostAvailable(); + } + + break; + case "inadequateSecurityError": + // Remove the "Try again" button from pages that don't need it. + // For HTTP/2 inadequate security, trying again won't help. + tryAgain.hidden = true; + break; + + case "malformedURI": + pageTitleId = "neterror-malformed-uri-page-title"; + // Remove the "Try again" button from pages that don't need it. + tryAgain.hidden = true; + break; + + // Pinning errors are of type nssFailure2 + case "nssFailure2": { + learnMore.hidden = false; + + const errorCode = document.getNetErrorInfo().errorCodeString; + switch (errorCode) { + case "SSL_ERROR_UNSUPPORTED_VERSION": + case "SSL_ERROR_PROTOCOL_VERSION_ALERT": { + const tlsNotice = document.getElementById("tlsVersionNotice"); + tlsNotice.hidden = false; + document.l10n.setAttributes(tlsNotice, "cert-error-old-tls-version"); + } + // fallthrough + + case "interrupted": // This happens with subresources that are above the max tls + case "SSL_ERROR_NO_CIPHERS_SUPPORTED": + case "SSL_ERROR_NO_CYPHER_OVERLAP": + case "SSL_ERROR_SSL_DISABLED": + RPMAddMessageListener("HasChangedCertPrefs", msg => { + if (msg.data.hasChangedCertPrefs) { + // Configuration overrides might have caused this; offer to reset. + showPrefChangeContainer(); + } + }); + RPMSendAsyncMessage("GetChangedCertPrefs"); + } + + break; + } + + case "sslv3Used": + learnMore.hidden = false; + document.body.className = "certerror"; + break; + } + + if (!KNOWN_ERROR_TITLE_IDS.has(bodyTitleId)) { + console.error("No strings exist for error:", gErrorCode); + bodyTitleId = "generic-title"; + } + + // The TRR errors may present options that direct users to settings only available on Firefox Desktop + if (RPMIsFirefox()) { + if (isTRROnlyFailure) { + document.body.className = "certerror"; // Shows warning icon + pageTitleId = "dns-not-found-trr-only-title2"; + document.l10n.setAttributes(docTitle, pageTitleId); + bodyTitleId = "dns-not-found-trr-only-title2"; + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = ""; + let skipReason = RPMGetTRRSkipReason(); + + // enable buttons + let trrExceptionButton = document.getElementById("trrExceptionButton"); + trrExceptionButton.addEventListener("click", () => { + RPMSendQuery("Browser:AddTRRExcludedDomain", { + hostname: HOST_NAME, + }).then(msg => { + retryThis(trrExceptionButton); + }); + }); + + let isTrrServerError = true; + if (RPMIsSiteSpecificTRRError()) { + // Only show the exclude button if the failure is specific to this + // domain. If the TRR server is inaccessible we don't want to allow + // the user to add an exception just for this domain. + trrExceptionButton.hidden = false; + isTrrServerError = false; + } + let trrSettingsButton = document.getElementById("trrSettingsButton"); + trrSettingsButton.addEventListener("click", () => { + RPMSendAsyncMessage("OpenTRRPreferences"); + }); + trrSettingsButton.hidden = false; + let message = document.getElementById("trrOnlyMessage"); + document.l10n.setAttributes( + message, + "neterror-dns-not-found-trr-only-reason", + { + hostname: HOST_NAME, + } + ); + + let descriptionTag = "neterror-dns-not-found-trr-unknown-problem"; + let args = { trrDomain: RPMGetTRRDomain() }; + if ( + skipReason == "TRR_FAILED" || + skipReason == "TRR_CHANNEL_DNS_FAIL" || + skipReason == "TRR_UNKNOWN_CHANNEL_FAILURE" || + skipReason == "TRR_NET_REFUSED" || + skipReason == "TRR_NET_INTERRUPT" || + skipReason == "TRR_NET_INADEQ_SEQURITY" + ) { + descriptionTag = "neterror-dns-not-found-trr-only-could-not-connect"; + } else if (skipReason == "TRR_TIMEOUT") { + descriptionTag = "neterror-dns-not-found-trr-only-timeout"; + } else if ( + skipReason == "TRR_IS_OFFLINE" || + skipReason == "TRR_NO_CONNECTIVITY" + ) { + descriptionTag = "neterror-dns-not-found-trr-offline"; + } else if ( + skipReason == "TRR_NO_ANSWERS" || + skipReason == "TRR_NXDOMAIN" + ) { + descriptionTag = "neterror-dns-not-found-trr-unknown-host2"; + } else if ( + skipReason == "TRR_DECODE_FAILED" || + skipReason == "TRR_SERVER_RESPONSE_ERR" + ) { + descriptionTag = "neterror-dns-not-found-trr-server-problem"; + } + + let trrMode = RPMGetIntPref("network.trr.mode").toString(); + recordTRREventTelemetry( + "TRROnlyFailure", + trrMode, + args.trrDomain, + skipReason + ); + + let description = document.getElementById("trrOnlyDescription"); + document.l10n.setAttributes(description, descriptionTag, args); + + const trrLearnMoreContainer = document.getElementById( + "trrLearnMoreContainer" + ); + trrLearnMoreContainer.hidden = false; + let trrOnlyLearnMoreLink = document.getElementById( + "trrOnlylearnMoreLink" + ); + if (isTrrServerError) { + // Go to DoH settings page + trrOnlyLearnMoreLink.href = "about:preferences#privacy-doh"; + trrOnlyLearnMoreLink.addEventListener("click", event => { + event.preventDefault(); + RPMSendAsyncMessage("OpenTRRPreferences"); + RPMRecordTelemetryEvent( + "security.doh.neterror", + "click", + "settings_button", + "TRROnlyFailure", + { + mode: trrMode, + provider_key: args.trrDomain, + skip_reason: skipReason, + } + ); + }); + } else { + // This will be replaced at a later point with a link to an offline support page + // https://bugzilla.mozilla.org/show_bug.cgi?id=1806257 + trrOnlyLearnMoreLink.href = + RPMGetFormatURLPref("network.trr_ui.skip_reason_learn_more_url") + + skipReason.toLowerCase().replaceAll("_", "-"); + } + + let div = document.getElementById("trrOnlyContainer"); + div.hidden = false; + + return; + } else if (isNativeFallbackWarning) { + showNativeFallbackWarning(); + return; + } + } + + document.l10n.setAttributes(docTitle, pageTitleId); + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = getDescription(); + setFocus("#netErrorButtonContainer > .try-again"); + + if (longDesc) { + const parts = getNetErrorDescParts(); + setNetErrorMessageFromParts(longDesc, parts); + } + + setNetErrorMessageFromCode(); +} + +function showNativeFallbackWarning() { + const docTitle = document.querySelector("title"); + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + + let pageTitleId = "neterror-page-title"; + let bodyTitleId = gErrorCode + "-title"; + + document.body.className = "certerror"; // Shows warning icon + pageTitleId = "dns-not-found-native-fallback-title2"; + document.l10n.setAttributes(docTitle, pageTitleId); + + bodyTitleId = "dns-not-found-native-fallback-title2"; + document.l10n.setAttributes(bodyTitle, bodyTitleId); + + shortDesc.textContent = ""; + let nativeFallbackIgnoreButton = document.getElementById( + "nativeFallbackIgnoreButton" + ); + nativeFallbackIgnoreButton.addEventListener("click", () => { + RPMSetBoolPref("network.trr.display_fallback_warning", false); + retryThis(nativeFallbackIgnoreButton); + }); + + let continueThisTimeButton = document.getElementById( + "nativeFallbackContinueThisTimeButton" + ); + continueThisTimeButton.addEventListener("click", () => { + RPMSetTRRDisabledLoadFlags(); + document.location.reload(); + }); + continueThisTimeButton.hidden = false; + + nativeFallbackIgnoreButton.hidden = false; + let message = document.getElementById("nativeFallbackMessage"); + document.l10n.setAttributes( + message, + "neterror-dns-not-found-native-fallback-reason", + { + hostname: HOST_NAME, + } + ); + let skipReason = RPMGetTRRSkipReason(); + let descriptionTag = "neterror-dns-not-found-trr-unknown-problem"; + let args = { trrDomain: RPMGetTRRDomain() }; + + if (skipReason.includes("HEURISTIC_TRIPPED")) { + descriptionTag = "neterror-dns-not-found-native-fallback-heuristic"; + } else if (skipReason == "TRR_NOT_CONFIRMED") { + descriptionTag = "neterror-dns-not-found-native-fallback-not-confirmed2"; + } + + let description = document.getElementById("nativeFallbackDescription"); + document.l10n.setAttributes(description, descriptionTag, args); + + let learnMoreContainer = document.getElementById( + "nativeFallbackLearnMoreContainer" + ); + learnMoreContainer.hidden = false; + + let learnMoreLink = document.getElementById("nativeFallbackLearnMoreLink"); + learnMoreLink.href = + RPMGetFormatURLPref("network.trr_ui.skip_reason_learn_more_url") + + skipReason.toLowerCase().replaceAll("_", "-"); + + let div = document.getElementById("nativeFallbackContainer"); + div.hidden = false; + + recordTRREventTelemetry( + "NativeFallbackWarning", + RPMGetIntPref("network.trr.mode").toString(), + args.trrDomain, + skipReason + ); +} +/** + * Builds HTML elements from `parts` and appends them to `parentElement`. + * + * @param {HTMLElement} parentElement + * @param {Array<["li" | "p" | "span", string, Record<string, string> | undefined]>} parts + */ +function setNetErrorMessageFromParts(parentElement, parts) { + let list = null; + + for (let [tag, l10nId, l10nArgs] of parts) { + const elem = document.createElement(tag); + elem.dataset.l10nId = l10nId; + if (l10nArgs) { + elem.dataset.l10nArgs = JSON.stringify(l10nArgs); + } + + if (tag === "li") { + if (!list) { + list = document.createElement("ul"); + parentElement.appendChild(list); + } + list.appendChild(elem); + } else { + if (list) { + list = null; + } + parentElement.appendChild(elem); + } + } +} + +/** + * Returns an array of tuples determining the parts of an error message: + * - HTML tag name + * - l10n id + * - l10n args (optional) + * + * @returns { Array<["li" | "p" | "span", string, Record<string, string> | undefined]> } + */ +function getNetErrorDescParts() { + switch (gErrorCode) { + case "connectionFailure": + case "netInterrupt": + case "netReset": + case "netTimeout": + return [ + ["li", "neterror-load-error-try-again"], + ["li", "neterror-load-error-connection"], + ["li", "neterror-load-error-firewall"], + ]; + + case "blockedByPolicy": + case "deniedPortAccess": + case "malformedURI": + return []; + + case "captivePortal": + return [["p", ""]]; + case "contentEncodingError": + return [["li", "neterror-content-encoding-error"]]; + case "corruptedContentErrorv2": + return [ + ["p", "neterror-corrupted-content-intro"], + ["li", "neterror-corrupted-content-contact-website"], + ]; + case "dnsNotFound": + return [ + ["span", "neterror-dns-not-found-hint-header"], + ["li", "neterror-dns-not-found-hint-try-again"], + ["li", "neterror-dns-not-found-hint-check-network"], + ["li", "neterror-dns-not-found-hint-firewall"], + ]; + case "fileAccessDenied": + return [["li", "neterror-access-denied"]]; + case "fileNotFound": + return [ + ["li", "neterror-file-not-found-filename"], + ["li", "neterror-file-not-found-moved"], + ]; + case "inadequateSecurityError": + return [ + ["p", "neterror-inadequate-security-intro", { hostname: HOST_NAME }], + ["p", "neterror-inadequate-security-code"], + ]; + case "mitm": { + const failedCertInfo = document.getFailedCertSecurityInfo(); + const errArgs = { + hostname: HOST_NAME, + mitm: getMitmName(failedCertInfo), + }; + return [["span", "certerror-mitm", errArgs]]; + } + case "netOffline": + return [["li", "neterror-net-offline"]]; + case "networkProtocolError": + return [ + ["p", "neterror-network-protocol-error-intro"], + ["li", "neterror-network-protocol-error-contact-website"], + ]; + case "notCached": + return [ + ["p", "neterror-not-cached-intro"], + ["li", "neterror-not-cached-sensitive"], + ["li", "neterror-not-cached-try-again"], + ]; + case "nssFailure2": + return [ + ["li", "neterror-nss-failure-not-verified"], + ["li", "neterror-nss-failure-contact-website"], + ]; + case "proxyConnectFailure": + return [ + ["li", "neterror-proxy-connect-failure-settings"], + ["li", "neterror-proxy-connect-failure-contact-admin"], + ]; + case "proxyResolveFailure": + return [ + ["li", "neterror-proxy-resolve-failure-settings"], + ["li", "neterror-proxy-resolve-failure-connection"], + ["li", "neterror-proxy-resolve-failure-firewall"], + ]; + case "redirectLoop": + return [["li", "neterror-redirect-loop"]]; + case "sslv3Used": + return [["span", "neterror-sslv3-used"]]; + case "unknownProtocolFound": + return [["li", "neterror-unknown-protocol"]]; + case "unknownSocketType": + return [ + ["li", "neterror-unknown-socket-type-psm-installed"], + ["li", "neterror-unknown-socket-type-server-config"], + ]; + case "unsafeContentType": + return [["li", "neterror-unsafe-content-type"]]; + + default: + return [["p", "neterror-generic-error"]]; + } +} + +function setNetErrorMessageFromCode() { + let errorCode; + try { + errorCode = document.getNetErrorInfo().errorCodeString; + } catch (ex) { + // We don't have a securityInfo when this is for example a DNS error. + return; + } + + let errorMessage; + if (errorCode) { + const l10nId = errorCode.replace(/_/g, "-").toLowerCase(); + if (KNOWN_ERROR_MESSAGE_IDS.has(l10nId)) { + const l10n = new Localization([ERROR_MESSAGES_FTL], true); + errorMessage = l10n.formatValueSync(l10nId); + } + + const shortDesc2 = document.getElementById("errorShortDesc2"); + document.l10n.setAttributes(shortDesc2, "cert-error-code-prefix", { + error: errorCode, + }); + } else { + console.warn("This error page has no error code in its security info"); + } + + let hostname = HOST_NAME; + const { port } = document.location; + if (port && port != 443) { + hostname += ":" + port; + } + + const shortDesc = document.getElementById("errorShortDesc"); + document.l10n.setAttributes(shortDesc, "cert-error-ssl-connection-error", { + errorMessage: errorMessage ?? errorCode ?? "", + hostname, + }); +} + +function setupBlockingReportingUI() { + let checkbox = document.getElementById("automaticallyReportBlockingInFuture"); + + let reportingAutomatic = RPMGetBoolPref( + "security.xfocsp.errorReporting.automatic" + ); + checkbox.checked = !!reportingAutomatic; + + checkbox.addEventListener("change", function ({ target: { checked } }) { + RPMSetBoolPref("security.xfocsp.errorReporting.automatic", checked); + + // If we're enabling reports, send a report for this failure. + if (checked) { + reportBlockingError(); + } + }); + + let reportingEnabled = RPMGetBoolPref( + "security.xfocsp.errorReporting.enabled" + ); + + if (reportingEnabled) { + // Display blocking error reporting UI for XFO error and CSP error. + document.getElementById("blockingErrorReporting").hidden = false; + + if (reportingAutomatic) { + reportBlockingError(); + } + } +} + +function reportBlockingError() { + // We only report if we are in a frame. + if (window === window.top) { + return; + } + + let err = gErrorCode; + // Ensure we only deal with XFO and CSP here. + if (!["xfoBlocked", "cspBlocked"].includes(err)) { + return; + } + + let xfo_header = RPMGetHttpResponseHeader("X-Frame-Options"); + let csp_header = RPMGetHttpResponseHeader("Content-Security-Policy"); + + // Extract the 'CSP: frame-ancestors' from the CSP header. + let reg = /(?:^|\s)frame-ancestors\s([^;]*)[$]*/i; + let match = reg.exec(csp_header); + csp_header = match ? match[1] : ""; + + // If it's the csp error page without the CSP: frame-ancestors, this means + // this error page is not triggered by CSP: frame-ancestors. So, we bail out + // early. + if (err === "cspBlocked" && !csp_header) { + return; + } + + let xfoAndCspInfo = { + error_type: err === "xfoBlocked" ? "xfo" : "csp", + xfo_header, + csp_header, + }; + + // Trimming the tail colon symbol. + let scheme = document.location.protocol.slice(0, -1); + + RPMSendAsyncMessage("ReportBlockingError", { + scheme, + host: document.location.host, + port: parseInt(document.location.port) || -1, + path: document.location.pathname, + xfoAndCspInfo, + }); +} + +function initPageCaptivePortal() { + document.body.className = "captiveportal"; + document.getElementById("returnButton").hidden = true; + const openButton = document.getElementById("openPortalLoginPageButton"); + openButton.hidden = false; + openButton.addEventListener("click", () => { + RPMSendAsyncMessage("Browser:OpenCaptivePortalPage"); + }); + + setFocus("#openPortalLoginPageButton"); + setupAdvancedButton(); + disallowCertOverridesIfNeeded(); + + // When the portal is freed, an event is sent by the parent process + // that we can pick up and attempt to reload the original page. + RPMAddMessageListener("AboutNetErrorCaptivePortalFreed", () => { + document.location.reload(); + }); +} + +function initPageCertError() { + document.body.classList.add("certerror"); + + setFocus("#returnButton"); + setupAdvancedButton(); + disallowCertOverridesIfNeeded(); + + const hideAddExceptionButton = RPMGetBoolPref( + "security.certerror.hideAddException", + false + ); + if (hideAddExceptionButton) { + document.getElementById("exceptionDialogButton").hidden = true; + } + + const els = document.querySelectorAll("[data-telemetry-id]"); + for (let el of els) { + el.addEventListener("click", recordClickTelemetry); + } + + const failedCertInfo = document.getFailedCertSecurityInfo(); + // Truncate the error code to avoid going over the allowed + // string size limit for telemetry events. + const errorCode = failedCertInfo.errorCodeString.substring(0, 40); + RPMRecordTelemetryEvent( + "security.ui.certerror", + "load", + "aboutcerterror", + errorCode, + { + has_sts: gHasSts.toString(), + is_frame: (window.parent != window).toString(), + } + ); + + setCertErrorDetails(); +} + +function recordClickTelemetry(e) { + let target = e.originalTarget; + let telemetryId = target.dataset.telemetryId; + let failedCertInfo = document.getFailedCertSecurityInfo(); + // Truncate the error code to avoid going over the allowed + // string size limit for telemetry events. + let errorCode = failedCertInfo.errorCodeString.substring(0, 40); + RPMRecordTelemetryEvent( + "security.ui.certerror", + "click", + telemetryId, + errorCode, + { + has_sts: gHasSts.toString(), + is_frame: (window.parent != window).toString(), + } + ); +} + +function initCertErrorPageActions() { + document.getElementById( + "certErrorAndCaptivePortalButtonContainer" + ).hidden = false; + document + .getElementById("returnButton") + .addEventListener("click", onReturnButtonClick); + document + .getElementById("advancedPanelReturnButton") + .addEventListener("click", onReturnButtonClick); + document + .getElementById("copyToClipboardTop") + .addEventListener("click", copyPEMToClipboard); + document + .getElementById("copyToClipboardBottom") + .addEventListener("click", copyPEMToClipboard); + document + .getElementById("exceptionDialogButton") + .addEventListener("click", addCertException); +} + +function addCertException() { + const isPermanent = + !RPMIsWindowPrivate() && + RPMGetBoolPref("security.certerrors.permanentOverride"); + document.addCertException(!isPermanent).then( + () => { + location.reload(); + }, + err => {} + ); +} + +function onReturnButtonClick(e) { + RPMSendAsyncMessage("Browser:SSLErrorGoBack"); +} + +function copyPEMToClipboard(e) { + const errorText = document.getElementById("certificateErrorText"); + navigator.clipboard.writeText(errorText.textContent); +} + +async function getFailedCertificatesAsPEMString() { + let locationUrl = document.location.href; + let failedCertInfo = document.getFailedCertSecurityInfo(); + let errorMessage = failedCertInfo.errorMessage; + let hasHSTS = failedCertInfo.hasHSTS.toString(); + let hasHPKP = failedCertInfo.hasHPKP.toString(); + let [hstsLabel, hpkpLabel, failedChainLabel] = + await document.l10n.formatValues([ + { id: "cert-error-details-hsts-label", args: { hasHSTS } }, + { id: "cert-error-details-key-pinning-label", args: { hasHPKP } }, + { id: "cert-error-details-cert-chain-label" }, + ]); + + let certStrings = failedCertInfo.certChainStrings; + let failedChainCertificates = ""; + for (let der64 of certStrings) { + let wrapped = der64.replace(/(\S{64}(?!$))/g, "$1\r\n"); + failedChainCertificates += + "-----BEGIN CERTIFICATE-----\r\n" + + wrapped + + "\r\n-----END CERTIFICATE-----\r\n"; + } + + let details = + locationUrl + + "\r\n\r\n" + + errorMessage + + "\r\n\r\n" + + hstsLabel + + "\r\n" + + hpkpLabel + + "\r\n\r\n" + + failedChainLabel + + "\r\n\r\n" + + failedChainCertificates; + return details; +} + +function setCertErrorDetails() { + // Check if the connection is being man-in-the-middled. When the parent + // detects an intercepted connection, the page may be reloaded with a new + // error code (MOZILLA_PKIX_ERROR_MITM_DETECTED). + const failedCertInfo = document.getFailedCertSecurityInfo(); + const mitmPrimingEnabled = RPMGetBoolPref( + "security.certerrors.mitm.priming.enabled" + ); + if ( + mitmPrimingEnabled && + failedCertInfo.errorCodeString == "SEC_ERROR_UNKNOWN_ISSUER" && + // Only do this check for top-level failures. + window.parent == window + ) { + RPMSendAsyncMessage("Browser:PrimeMitm"); + } + + document.body.setAttribute("code", failedCertInfo.errorCodeString); + + const learnMore = document.getElementById("learnMoreContainer"); + learnMore.hidden = false; + const learnMoreLink = document.getElementById("learnMoreLink"); + const baseURL = RPMGetFormatURLPref("app.support.baseURL"); + learnMoreLink.href = baseURL + "connection-not-secure"; + + const bodyTitle = document.querySelector(".title-text"); + const shortDesc = document.getElementById("errorShortDesc"); + const shortDesc2 = document.getElementById("errorShortDesc2"); + + let whatToDoParts = null; + + switch (failedCertInfo.errorCodeString) { + case "SSL_ERROR_BAD_CERT_DOMAIN": + whatToDoParts = [ + ["p", "certerror-bad-cert-domain-what-can-you-do-about-it"], + ]; + break; + + case "SEC_ERROR_OCSP_INVALID_SIGNING_CERT": // FIXME - this would have thrown? + break; + + case "SEC_ERROR_UNKNOWN_ISSUER": + whatToDoParts = [ + ["p", "certerror-unknown-issuer-what-can-you-do-about-it-website"], + [ + "p", + "certerror-unknown-issuer-what-can-you-do-about-it-contact-admin", + ], + ]; + break; + + // This error code currently only exists for the Symantec distrust + // in Firefox 63, so we add copy explaining that to the user. + // In case of future distrusts of that scale we might need to add + // additional parameters that allow us to identify the affected party + // without replicating the complex logic from certverifier code. + case "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED": { + document.l10n.setAttributes( + shortDesc2, + "cert-error-symantec-distrust-description", + { hostname: HOST_NAME } + ); + + // FIXME - this does nothing + const adminDesc = document.createElement("p"); + document.l10n.setAttributes( + adminDesc, + "cert-error-symantec-distrust-admin" + ); + + learnMoreLink.href = baseURL + "symantec-warning"; + break; + } + + case "MOZILLA_PKIX_ERROR_MITM_DETECTED": { + const autoEnabledEnterpriseRoots = RPMGetBoolPref( + "security.enterprise_roots.auto-enabled", + false + ); + if (mitmPrimingEnabled && autoEnabledEnterpriseRoots) { + RPMSendAsyncMessage("Browser:ResetEnterpriseRootsPref"); + } + + learnMoreLink.href = baseURL + "security-error"; + + document.l10n.setAttributes(bodyTitle, "certerror-mitm-title"); + + document.l10n.setAttributes(shortDesc, "certerror-mitm", { + hostname: HOST_NAME, + mitm: getMitmName(failedCertInfo), + }); + + const id3 = gHasSts + ? "certerror-mitm-what-can-you-do-about-it-attack-sts" + : "certerror-mitm-what-can-you-do-about-it-attack"; + whatToDoParts = [ + ["li", "certerror-mitm-what-can-you-do-about-it-antivirus"], + ["li", "certerror-mitm-what-can-you-do-about-it-corporate"], + ["li", id3, { mitm: getMitmName(failedCertInfo) }], + ]; + break; + } + + case "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT": + learnMoreLink.href = baseURL + "security-error"; + break; + + // In case the certificate expired we make sure the system clock + // matches the remote-settings service (blocklist via Kinto) ping time + // and is not before the build date. + case "SEC_ERROR_EXPIRED_CERTIFICATE": + case "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE": + case "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE": + case "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE": { + learnMoreLink.href = baseURL + "time-errors"; + + // We check against the remote-settings server time first if available, because that allows us + // to give the user an approximation of what the correct time is. + const difference = RPMGetIntPref( + "services.settings.clock_skew_seconds", + 0 + ); + const lastFetched = + RPMGetIntPref("services.settings.last_update_seconds", 0) * 1000; + + // This is set to true later if the user's system clock is at fault for this error. + let clockSkew = false; + + const now = Date.now(); + const certRange = { + notBefore: failedCertInfo.certValidityRangeNotBefore, + notAfter: failedCertInfo.certValidityRangeNotAfter, + }; + const approximateDate = now - difference * 1000; + // If the difference is more than a day, we last fetched the date in the last 5 days, + // and adjusting the date per the interval would make the cert valid, warn the user: + if ( + Math.abs(difference) > 60 * 60 * 24 && + now - lastFetched <= 60 * 60 * 24 * 5 * 1000 && + certRange.notBefore < approximateDate && + certRange.notAfter > approximateDate + ) { + clockSkew = true; + // If there is no clock skew with Kinto servers, check against the build date. + // (The Kinto ping could have happened when the time was still right, or not at all) + } else { + const appBuildID = RPMGetAppBuildID(); + const year = parseInt(appBuildID.substr(0, 4), 10); + const month = parseInt(appBuildID.substr(4, 2), 10) - 1; + const day = parseInt(appBuildID.substr(6, 2), 10); + + const buildDate = new Date(year, month, day); + + // We don't check the notBefore of the cert with the build date, + // as it is of course almost certain that it is now later than the build date, + // so we shouldn't exclude the possibility that the cert has become valid + // since the build date. + if (buildDate > now && new Date(certRange.notAfter) > buildDate) { + clockSkew = true; + } + } + + if (clockSkew) { + document.body.classList.add("clockSkewError"); + document.l10n.setAttributes(bodyTitle, "clockSkewError-title"); + document.l10n.setAttributes(shortDesc, "neterror-clock-skew-error", { + hostname: HOST_NAME, + now, + }); + document.getElementById("returnButton").hidden = true; + document.getElementById("certErrorTryAgainButton").hidden = false; + document.getElementById("advancedButton").hidden = true; + + document.getElementById("advancedPanelReturnButton").hidden = true; + document.getElementById("advancedPanelTryAgainButton").hidden = false; + document.getElementById("exceptionDialogButton").hidden = true; + break; + } + + document.l10n.setAttributes(shortDesc, "certerror-expired-cert-intro", { + hostname: HOST_NAME, + }); + + // The secondary description mentions expired certificates explicitly + // and should only be shown if the certificate has actually expired + // instead of being not yet valid. + if (failedCertInfo.errorCodeString == "SEC_ERROR_EXPIRED_CERTIFICATE") { + const sd2Id = gHasSts + ? "certerror-expired-cert-sts-second-para" + : "certerror-expired-cert-second-para"; + document.l10n.setAttributes(shortDesc2, sd2Id); + if ( + Math.abs(difference) <= 60 * 60 * 24 && + now - lastFetched <= 60 * 60 * 24 * 5 * 1000 + ) { + whatToDoParts = [ + ["p", "certerror-bad-cert-domain-what-can-you-do-about-it"], + ]; + } + } + + whatToDoParts ??= [ + [ + "p", + "certerror-expired-cert-what-can-you-do-about-it-clock", + { hostname: HOST_NAME, now }, + ], + [ + "p", + "certerror-expired-cert-what-can-you-do-about-it-contact-website", + ], + ]; + break; + } + } + + if (whatToDoParts) { + setNetErrorMessageFromParts( + document.getElementById("errorWhatToDoText"), + whatToDoParts + ); + document.getElementById("errorWhatToDo").hidden = false; + } +} + +async function getSubjectAltNames(failedCertInfo) { + const serverCertBase64 = failedCertInfo.certChainStrings[0]; + const parsed = await parse(pemToDER(serverCertBase64)); + const subjectAltNamesExtension = parsed.ext.san; + const subjectAltNames = []; + if (subjectAltNamesExtension) { + for (let [key, value] of subjectAltNamesExtension.altNames) { + if (key === "DNS Name" && value.length) { + subjectAltNames.push(value); + } + } + } + return subjectAltNames; +} + +// The optional argument is only here for testing purposes. +function setTechnicalDetailsOnCertError( + failedCertInfo = document.getFailedCertSecurityInfo() +) { + let technicalInfo = document.getElementById("badCertTechnicalInfo"); + technicalInfo.textContent = ""; + + function addLabel(l10nId, args = null, attrs = null) { + let elem = document.createElement("label"); + technicalInfo.appendChild(elem); + + let newLines = document.createTextNode("\n \n"); + technicalInfo.appendChild(newLines); + + if (attrs) { + let link = document.createElement("a"); + for (let [attr, value] of Object.entries(attrs)) { + link.setAttribute(attr, value); + } + elem.appendChild(link); + } + + document.l10n.setAttributes(elem, l10nId, args); + } + + function addErrorCodeLink() { + addLabel( + "cert-error-code-prefix-link", + { error: failedCertInfo.errorCodeString }, + { + title: failedCertInfo.errorCodeString, + id: "errorCode", + "data-l10n-name": "error-code-link", + "data-telemetry-id": "error_code_link", + href: "#certificateErrorDebugInformation", + } + ); + + // We're attaching the event listener to the parent element and not on + // the errorCodeLink itself because event listeners cannot be attached + // to fluent DOM overlays. + technicalInfo.addEventListener("click", event => { + if (event.target.id === "errorCode") { + event.preventDefault(); + toggleCertErrorDebugInfoVisibility(); + recordClickTelemetry(event); + } + }); + } + + let hostname = HOST_NAME; + const { port } = document.location; + if (port && port != 443) { + hostname += ":" + port; + } + + switch (failedCertInfo.overridableErrorCategory) { + case "trust-error": + switch (failedCertInfo.errorCodeString) { + case "MOZILLA_PKIX_ERROR_MITM_DETECTED": + addLabel("cert-error-mitm-intro"); + addLabel("cert-error-mitm-mozilla"); + addLabel("cert-error-mitm-connection"); + break; + case "SEC_ERROR_UNKNOWN_ISSUER": + addLabel("cert-error-trust-unknown-issuer-intro"); + addLabel("cert-error-trust-unknown-issuer", { hostname }); + break; + case "SEC_ERROR_CA_CERT_INVALID": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-cert-invalid"); + break; + case "SEC_ERROR_UNTRUSTED_ISSUER": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-untrusted-issuer"); + break; + case "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-signature-algorithm-disabled"); + break; + case "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-expired-issuer"); + break; + case "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-self-signed"); + break; + case "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED": + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-trust-symantec"); + break; + default: + addLabel("cert-error-intro", { hostname }); + addLabel("cert-error-untrusted-default"); + } + addErrorCodeLink(); + break; + + case "expired-or-not-yet-valid": { + const notBefore = failedCertInfo.validNotBefore; + const notAfter = failedCertInfo.validNotAfter; + if (notBefore && Date.now() < notAfter) { + addLabel("cert-error-not-yet-valid-now", { + hostname, + "not-before-local-time": formatter.format(new Date(notBefore)), + }); + } else { + addLabel("cert-error-expired-now", { + hostname, + "not-after-local-time": formatter.format(new Date(notAfter)), + }); + } + addErrorCodeLink(); + break; + } + + case "domain-mismatch": + getSubjectAltNames(failedCertInfo).then(subjectAltNames => { + if (!subjectAltNames.length) { + addLabel("cert-error-domain-mismatch", { hostname }); + } else if (subjectAltNames.length > 1) { + const names = subjectAltNames.join(", "); + addLabel("cert-error-domain-mismatch-multiple", { + hostname, + "subject-alt-names": names, + }); + } else { + const altName = subjectAltNames[0]; + + // If the alt name is a wildcard domain ("*.example.com") + // let's use "www" instead. "*.example.com" isn't going to + // get anyone anywhere useful. bug 432491 + const okHost = altName.replace(/^\*\./, "www."); + + // Let's check if we want to make this a link. + const showLink = + /* case #1: + * example.com uses an invalid security certificate. + * + * The certificate is only valid for www.example.com + * + * Make sure to include the "." ahead of thisHost so that a + * MitM attack on paypal.com doesn't hyperlink to "notpaypal.com" + * + * We'd normally just use a RegExp here except that we lack a + * library function to escape them properly (bug 248062), and + * domain names are famous for having '.' characters in them, + * which would allow spurious and possibly hostile matches. + */ + okHost.endsWith("." + HOST_NAME) || + /* case #2: + * browser.garage.maemo.org uses an invalid security certificate. + * + * The certificate is only valid for garage.maemo.org + */ + HOST_NAME.endsWith("." + okHost); + + const l10nArgs = { hostname, "alt-name": altName }; + if (showLink) { + // Set the link if we want it. + const proto = document.location.protocol + "//"; + addLabel("cert-error-domain-mismatch-single", l10nArgs, { + href: proto + okHost, + "data-l10n-name": "domain-mismatch-link", + id: "cert_domain_link", + }); + + // If we set a link, meaning there's something helpful for + // the user here, expand the section by default + if (getCSSClass() != "expertBadCert") { + revealAdvancedPanelSlowlyAsync(); + } + } else { + addLabel("cert-error-domain-mismatch-single-nolink", l10nArgs); + } + } + addErrorCodeLink(); + }); + break; + } + + getFailedCertificatesAsPEMString().then(pemString => { + const errorText = document.getElementById("certificateErrorText"); + errorText.textContent = pemString; + }); +} + +/* Only focus if we're the toplevel frame; otherwise we + don't want to call attention to ourselves! +*/ +function setFocus(selector, position = "afterbegin") { + if (window.top == window) { + var button = document.querySelector(selector); + button.parentNode.insertAdjacentElement(position, button); + // It's possible setFocus was called via the DOMContentLoaded event + // handler and that the button has no frame. Things without a frame cannot + // be focused. We use a requestAnimationFrame to queue up the focus to occur + // once the button has its frame. + requestAnimationFrame(() => { + button.focus({ focusVisible: false }); + }); + } +} + +for (let button of document.querySelectorAll(".try-again")) { + button.addEventListener("click", function () { + retryThis(this); + }); +} + +initPage(); + +// Dispatch this event so tests can detect that we finished loading the error page. +document.dispatchEvent(new CustomEvent("AboutNetErrorLoad", { bubbles: true })); |