/* 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`
${whatCanYouDoItems.map(id => 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``;
}
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()}`}
`;
}
}