/* 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-disable import/no-unassigned-import */ import { gHasSts, gIsCertError, gErrorCode, isCaptive, getCSSClass, getHostName, getSubjectAltNames, getFailedCertificatesAsPEMString, handleNSSFailure, recordSecurityUITelemetry, getFilePath, gOffline, gNoConnectivity, retryThis, VPN_ACTIVE, } from "chrome://global/content/aboutNetErrorHelpers.mjs"; import { initializeRegistry } from "chrome://global/content/errors/error-registry.mjs"; import { getResolvedErrorConfig, isFeltPrivacySupported, } from "chrome://global/content/errors/error-lookup.mjs"; import { html } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; import { NET_ERROR_ILLUSTRATIONS } from "chrome://global/content/errors/net-error-illustrations.mjs"; import "chrome://global/content/elements/moz-button-group.mjs"; import "chrome://global/content/elements/moz-button.mjs"; import "chrome://global/content/elements/moz-support-link.mjs"; const HOST_NAME = getHostName(); const FELT_PRIVACY_REFRESH = RPMGetBoolPref( "security.certerrors.felt-privacy-v1", false ); export class NetErrorCard extends MozLitElement { static properties = { hostname: { type: String }, domainMismatchNames: { type: String }, advancedShowing: { type: Boolean, reflect: true }, certErrorDebugInfoShowing: { type: Boolean, reflect: true }, certificateErrorText: { type: String }, showPrefReset: { type: Boolean }, showTlsNotice: { type: Boolean }, showTrrSettingsButton: { type: Boolean }, }; static queries = { copyButtonTop: "#copyToClipboardTop", copyButtonBot: "#copyToClipboardBot", exceptionButton: "#exception-button", errorCode: "#errorCode", advancedContainer: ".advanced-container", advancedButton: "#advanced-button", errorIntro: "#error-intro", certErrorDebugInfo: "#certificateErrorDebugInformation", certErrorText: "#certificateErrorText", viewCertificate: "#viewCertificate", errorTitle: "#error-title", returnButton: "#returnButton", learnMoreLink: "#error-learn-more-link", whatCanYouDo: "#whatCanYouDo", whyDangerous: "#fp-why-site-dangerous", tryAgainButton: "#tryAgainButton", prefResetButton: "#prefResetButton", tlsNotice: "#tlsVersionNotice", badStsCertExplanation: "#badStsCertExplanation", }; static getCustomErrorID(defaultCode) { // gNoConnectivity is only true when there's no network connectivity, // regardless of whether "Work Offline" mode is enabled. NS_ERROR_OFFLINE // is the error ID for real connectivity loss, while netOffline is the // error code for when "Work Offline" mode is enabled. if (gNoConnectivity) { return "NS_ERROR_OFFLINE"; } if (defaultCode === "proxyConnectFailure" && VPN_ACTIVE) { return "vpnFailure"; } return defaultCode; } static isSupported() { if (!FELT_PRIVACY_REFRESH) { return false; } initializeRegistry(); let errorInfo = { errorCodeString: "" }; try { errorInfo = gIsCertError ? document.getFailedCertSecurityInfo() : document.getNetErrorInfo(); } catch {} const id = NetErrorCard.getCustomErrorID( errorInfo.errorCodeString || gErrorCode ); return isFeltPrivacySupported(id); } constructor() { super(); this.domainMismatchNames = null; this.advancedShowing = false; this.certErrorDebugInfoShowing = false; this.certificateErrorText = null; this.domainMismatchNamesPromise = null; this.certificateErrorTextPromise = null; this.showCustomNetErrorCard = false; this.showPrefReset = false; this.showTlsNotice = false; this.showTrrSettingsButton = false; this.trrTelemetryData = null; } async getUpdateComplete() { // Fetch domain mismatch names and cert error text before rendering // to ensure Fluent localization has all required variables const promises = [ this.errorConfig?.advanced?.requiresDomainMismatchNames && !this.domainMismatchNames && this.getDomainMismatchNames(), document.getFailedCertSecurityInfo && !this.certificateErrorText && this.getCertificateErrorText(), this.domainMismatchNamesPromise, this.certificateErrorTextPromise, ].filter(Boolean); if (promises.length) { await Promise.all(promises); } return super.getUpdateComplete(); } connectedCallback() { super.connectedCallback(); this.init(); } firstUpdated() { // Dispatch this event so tests can detect that we finished loading the error page. document.dispatchEvent( new CustomEvent("AboutNetErrorLoad", { bubbles: true }) ); this.focusTryAgainButton(); } shouldHideExceptionButton() { let prefValue = RPMGetBoolPref( "security.certerror.hideAddException", false ); if (prefValue || this.errorConfig.hasNoUserFix) { return true; } const isIframed = window.self !== window.top; return gHasSts || !this.errorInfo.errorIsOverridable || isIframed; } init() { this.hostname = HOST_NAME; this.errorInfo = this.getErrorInfo(); this.errorConfig = this.getErrorConfig(); this.hideExceptionButton = this.shouldHideExceptionButton(); const titles = { net: "neterror-page-title", blocked: "neterror-blocked-by-policy-page-title", }; document.l10n.setAttributes( document.querySelector("title"), titles[this.errorConfig.category] ?? "fp-certerror-page-title" ); // Record telemetry when the error page loads if (gIsCertError && !isCaptive()) { recordSecurityUITelemetry( "securityUiCerterror", "loadAboutcerterror", this.errorInfo ); } // nssFailure2 are TLS errors which are tracked by load_abouttlserror if (!gIsCertError && gErrorCode !== "nssFailure2" && !isCaptive()) { let neterrorInfo = Object.assign({}, this.errorInfo); if (!neterrorInfo.errorCodeString) { neterrorInfo.errorCodeString = gErrorCode; } recordSecurityUITelemetry( "securityUiNeterror", "loadAboutneterror", neterrorInfo ); } // 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 mitmPrimingEnabled = RPMGetBoolPref( "security.certerrors.mitm.priming.enabled" ); if ( mitmPrimingEnabled && this.errorConfig.errorCode == "SEC_ERROR_UNKNOWN_ISSUER" && // Only do this check for top-level failures. window.parent == window ) { RPMSendAsyncMessage("Browser:PrimeMitm"); } // 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, }); } if (getCSSClass() == "expertBadCert") { this.toggleAdvancedShowing(); } this.checkAndRecordTRRTelemetry(); this.checkForDomainSuggestions(); } // Check for alternate host for dnsNotFound errors. checkForDomainSuggestions() { if (gErrorCode == "dnsNotFound" && !this.isTRROnlyFailure()) { RPMCheckAlternateHostAvailable(); } } isTRROnlyFailure() { return gErrorCode == "dnsNotFound" && RPMIsTRROnlyFailure(); } checkAndRecordTRRTelemetry() { if (!this.isTRROnlyFailure() || isCaptive()) { return; } this.recordTRRLoadTelemetry(); this.showTrrSettingsButton = true; } recordTRRLoadTelemetry() { const trrMode = RPMGetIntPref("network.trr.mode"); const trrDomain = RPMGetTRRDomain(); const skipReason = RPMGetTRRSkipReason(); this.trrTelemetryData = { value: "TRROnlyFailure", mode: trrMode.toString(), provider_key: trrDomain, skip_reason: skipReason, }; RPMRecordGleanEvent("securityDohNeterror", "loadDohwarning", { value: "TRROnlyFailure", mode: trrMode, provider_key: trrDomain, skip_reason: skipReason, }); } handlePrefChangeDetected() { this.showPrefReset = true; this.focusPrefResetButton(); } async focusTryAgainButton() { await this.getUpdateComplete(); if (window.top != window) { return; } if (!this.tryAgainButton) { return; } await this.tryAgainButton.updateComplete; this.tryAgainButton.focus(); } async focusPrefResetButton() { await this.getUpdateComplete(); if (window.top != window) { return; } if (!this.prefResetButton) { return; } requestAnimationFrame(() => { this.prefResetButton.focus(); }); } handlePrefResetClick() { RPMSendAsyncMessage("Browser:ResetSSLPreferences"); } prefResetContainerTemplate() { if (!this.showPrefReset) { return null; } return html`

`; } getErrorInfo() { try { return gIsCertError ? document.getFailedCertSecurityInfo() : document.getNetErrorInfo(); } catch { return { errorCodeString: gErrorCode }; } } getErrorConfig() { const id = NetErrorCard.getCustomErrorID( this.errorInfo.errorCodeString || gErrorCode ); const errorConfig = getResolvedErrorConfig(id, { hostname: this.hostname, errorInfo: this.errorInfo, cssClass: getCSSClass(), domainMismatchNames: this.domainMismatchNames, offline: gOffline, filePath: getFilePath(), }); if (errorConfig.customNetError) { this.showCustomNetErrorCard = true; } if (gErrorCode === "nssFailure2") { const result = handleNSSFailure(() => this.handlePrefChangeDetected()); if (result.versionError) { this.showTlsNotice = true; } } return errorConfig; } introContentTemplate() { const config = this.errorConfig; if (!config.introContent) { return null; } const elementId = "error-intro"; if (Array.isArray(config.introContent)) { return html`

${config.introContent.map( ic => html`` )}

`; } const { dataL10nId, dataL10nArgs } = config.introContent; if (config.errorCode === "NS_ERROR_BASIC_HTTP_AUTH_DISABLED") { return html`

${this.hideExceptionButton ? html`

` : null} `; } // Handle HSTS certificate errors with additional explanation // For HSTS errors, we show additional explanation about why they can't bypass return html`

${gHasSts ? html`

` : null} `; } advancedContainerTemplate() { if (!this.advancedShowing) { return null; } const config = this.errorConfig; if (!config?.advanced) { return null; } const content = this.advancedSectionTemplate( this.mapAdvancedConfigToParams(config.advanced) ); return html`

${content}
`; } mapAdvancedConfigToParams(advancedConfig) { const params = { whyDangerousL10nId: advancedConfig.whyDangerous?.dataL10nId, whyDangerousL10nArgs: advancedConfig.whyDangerous?.dataL10nArgs, whatCanYouDoL10nId: advancedConfig.whatCanYouDo?.dataL10nId, whatCanYouDoL10nArgs: advancedConfig.whatCanYouDo?.dataL10nArgs, importantNote: advancedConfig.importantNote, learnMoreL10nId: advancedConfig.learnMore?.dataL10nId, learnMoreSupportPage: advancedConfig.learnMore?.supportPage, viewCert: advancedConfig.showViewCertificate, viewDateTime: advancedConfig.showDateTime, }; // Inject hostname into args that need it if (params.whyDangerousL10nArgs) { if (params.whyDangerousL10nArgs.hostname === null) { params.whyDangerousL10nArgs = { ...params.whyDangerousL10nArgs, hostname: this.hostname, }; } // Handle SSL_ERROR_BAD_CERT_DOMAIN's validHosts arg if (params.whyDangerousL10nArgs.validHosts === null) { params.whyDangerousL10nArgs = { ...params.whyDangerousL10nArgs, validHosts: this.domainMismatchNames ?? "", }; } } // Handle whatCanYouDo date args if (params.whatCanYouDoL10nArgs?.date === null) { params.whatCanYouDoL10nArgs = { ...params.whatCanYouDoL10nArgs, date: Date.now(), }; } return params; } getNSSErrorWhyDangerousL10nId(errorString) { return errorString.toLowerCase().replace(/_/g, "-"); } advancedSectionTemplate(params) { let { whyDangerousL10nId, whyDangerousL10nArgs, whatCanYouDoL10nId, whatCanYouDoL10nArgs, importantNote, learnMoreL10nId, learnMoreL10nArgs, learnMoreSupportPage, viewCert, viewDateTime, } = params; return html`
${whyDangerousL10nId ? html`

` : null}
${whatCanYouDoL10nId ? html`

` : null} ${importantNote ? html`

` : null} ${this.prefResetContainerTemplate()} ${this.tlsNoticeTemplate()} ${viewCert ? html`

` : null} ${learnMoreL10nId ? html`

` : null} ${this.errorConfig?.errorCode && gIsCertError ? html`

` : null} ${this.errorConfig?.errorCode && !gIsCertError ? html`

` : null} ${viewDateTime ? html`

` : null} ${!this.hideExceptionButton ? html` ` : null} `; } tlsNoticeTemplate() { if (!this.showTlsNotice) { return null; } return html`

`; } customNetErrorContainerTemplate() { if (!this.showCustomNetErrorCard) { return null; } const config = this.errorConfig; if (!config.customNetError) { // For errors with advanced sections but no custom net error section if (config.buttons?.showAdvanced) { const content = this.customNetErrorSectionTemplate({ titleL10nId: config.bodyTitleL10nId || "fp-certerror-body-title", buttons: { goBack: config.buttons?.showGoBack && window.self === window.top, tryAgain: config.buttons?.showTryAgain, }, useAdvancedSection: true, }); return html`
${content}
`; } return null; } const customNetError = config.customNetError; const params = this.mapCustomNetErrorConfigToParams(customNetError, config); const content = this.customNetErrorSectionTemplate(params); return html`
${content}
`; } mapCustomNetErrorConfigToParams(customNetError, config) { const params = { titleL10nId: customNetError.titleL10nId, whyDangerousL10nId: customNetError.whyDangerousL10nId, whyDangerousL10nArgs: customNetError.whyDangerousL10nArgs, whyDidThisHappenL10nId: customNetError.whyDidThisHappenL10nId, whyDidThisHappenL10nArgs: customNetError.whyDidThisHappenL10nArgs, whatCanYouDoL10nId: customNetError.whatCanYouDoL10nId, whatCanYouDoL10nArgs: customNetError.whatCanYouDoL10nArgs, whatCanYouDoItems: customNetError.whatCanYouDoItems, learnMoreL10nId: customNetError.learnMoreL10nId, learnMoreSupportPage: customNetError.learnMoreSupportPage, buttons: { tryAgain: config.buttons?.showTryAgain, goBack: config.buttons?.showGoBack && window.self === window.top, }, useAdvancedSection: config.buttons?.showAdvanced, }; // Inject hostname into args that need it if (params.whatCanYouDoL10nArgs?.hostname === null) { params.whatCanYouDoL10nArgs = { ...params.whatCanYouDoL10nArgs, hostname: this.hostname, }; } if (params.whyDidThisHappenL10nArgs?.hostname === null) { params.whyDidThisHappenL10nArgs = { ...params.whyDidThisHappenL10nArgs, hostname: this.hostname, }; } return params; } returnButtonTemplate() { return html``; } tryAgainButtonTemplate() { return html``; } customNetErrorSectionTemplate(params) { const { titleL10nId, whyDangerousL10nId, whyDangerousL10nArgs, whyDidThisHappenL10nId, whyDidThisHappenL10nArgs, whatCanYouDoL10nId, whatCanYouDoL10nArgs, whatCanYouDoItems, learnMoreL10nId, learnMoreSupportPage, buttons = {}, useAdvancedSection, } = params; const { goBack = false, tryAgain = false } = buttons; // Format the learn more link with base URL if it's a SUMO slug let learnMoreHref = learnMoreSupportPage; if ( learnMoreSupportPage && !learnMoreSupportPage.startsWith("http://") && !learnMoreSupportPage.startsWith("https://") ) { const baseURL = RPMGetFormatURLPref("app.support.baseURL"); learnMoreHref = baseURL + learnMoreSupportPage; } let whatCanYouDoSection = null; if (whatCanYouDoItems?.length) { whatCanYouDoSection = html`

`; } else if (whatCanYouDoL10nId) { whatCanYouDoSection = html`

`; } const content = html` ${whyDangerousL10nId ? html`

` : null} ${whatCanYouDoSection} ${whyDidThisHappenL10nId ? html`

` : null} ${learnMoreL10nId ? html`

` : null} ${tryAgain ? html` ${this.tryAgainButtonTemplate()} ${this.showTrrSettingsButton ? html`` : null} ` : null} ${goBack ? html`${this.returnButtonTemplate()}` : null} `; return html`

${this.introContentTemplate()} ${useAdvancedSection ? html` ${goBack ? this.returnButtonTemplate() : null} ${tryAgain ? this.tryAgainButtonTemplate() : null} ` : content} ${useAdvancedSection ? this.advancedContainerTemplate() : null} `; } async getDomainMismatchNames() { if (this.domainMismatchNamesPromise) { return; } this.domainMismatchNamesPromise = getSubjectAltNames(this.errorInfo); let subjectAltNames = await this.domainMismatchNamesPromise; this.domainMismatchNames = subjectAltNames.join(", "); // Re-resolve errorConfig to display domain mismatch names if (this.errorConfig?.advanced?.requiresDomainMismatchNames) { this.errorConfig = this.getErrorConfig(); } } async getCertificateErrorText() { if (this.certificateErrorTextPromise) { return; } this.certificateErrorTextPromise = getFailedCertificatesAsPEMString(); this.certificateErrorText = await this.certificateErrorTextPromise; } certErrorDebugInfoTemplate() { if (!this.certErrorDebugInfoShowing) { return null; } if (!this.certificateErrorText) { this.getCertificateErrorText(); return null; } return html`
${this.certificateErrorText}
`; } handleGoBackClick(e) { this.handleTelemetryClick(e); RPMSendAsyncMessage("Browser:SSLErrorGoBack"); } handleProceedToUrlClick(e) { this.handleTelemetryClick(e); const isPermanent = !RPMIsWindowPrivate() && RPMGetBoolPref("security.certerrors.permanentOverride"); document.addCertException(!isPermanent).then( () => { location.reload(); }, () => {} ); } handleTryAgain(e) { this.handleTelemetryClick(e); retryThis(e); } handleTRRSettingsClick(e) { this.handleTelemetryClick(e); RPMSendAsyncMessage("OpenTRRPreferences"); } toggleAdvancedShowing(e) { if (e) { this.handleTelemetryClick(e); } this.advancedShowing = !this.advancedShowing; if (!this.advancedShowing) { return; } this.revealAdvancedContainer(); } async revealAdvancedContainer() { await this.getUpdateComplete(); // 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. this.certErrorDebugInfoShowing = false; if (!this.exceptionButton) { this.resetReveal = null; return; } // Reveal, but disabled (and grayed-out) for 3.0s. if (this.exceptionButton) { this.exceptionButton.disabled = true; } // - if (this.resetReveal) { this.resetReveal(); // Reset if previous is pending. } let wasReset = false; this.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 || !this.advancedShowing) { this.resetReveal = null; return; } // Enable and un-gray-out. if (this.exceptionButton) { this.exceptionButton.disabled = false; } } async toggleCertErrorDebugInfoShowing(event) { this.handleTelemetryClick(event); event.preventDefault(); this.certErrorDebugInfoShowing = !this.certErrorDebugInfoShowing; if (this.certErrorDebugInfoShowing) { await this.getUpdateComplete(); this.copyButtonTop.scrollIntoView({ block: "start", behavior: "smooth", }); this.copyButtonTop.focus(); } } copyCertErrorTextToClipboard(e) { this.handleTelemetryClick(e); navigator.clipboard.writeText(this.certificateErrorText); } handleTelemetryClick(event) { let target = event.originalTarget; if (!target.hasAttribute("data-telemetry-id")) { target = target.getRootNode().host; } let telemetryId = target.dataset.telemetryId; if (this.trrTelemetryData) { RPMRecordGleanEvent( "securityDohNeterror", "click" + telemetryId .split("_") .map(word => word[0].toUpperCase() + word.slice(1)) .join(""), this.trrTelemetryData ); } else { const category = gIsCertError ? "securityUiCerterror" : "securityUiNeterror"; void recordSecurityUITelemetry( category, "click" + telemetryId .split("_") .map(word => word[0].toUpperCase() + word.slice(1)) .join(""), this.errorInfo ); } } render() { if (!this.errorInfo) { return null; } const { bodyTitleL10nId, image } = this.errorConfig; const { src, alt } = image ?? NET_ERROR_ILLUSTRATIONS.securityError; const title = bodyTitleL10nId ?? "fp-certerror-body-title"; return html`
${this.showCustomNetErrorCard ? html`${this.customNetErrorContainerTemplate()}` : html`

${this.introContentTemplate()} ${this.returnButtonTemplate()} ${this.advancedContainerTemplate()} ${this.certErrorDebugInfoTemplate()}`}
`; } }