From 5bf9a5152256b3331165a966de6d831d47f5bd46 Mon Sep 17 00:00:00 2001
From: Nelson Chan <3271800+chakflying@users.noreply.github.com>
Date: Mon, 1 Dec 2025 10:12:47 +0800
Subject: [PATCH] Feat: Add warning for cert. hostname mismatch (#3942)
Co-authored-by: Louis Lam
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
server/model/monitor.js | 4 +-
server/util-server.js | 24 ++++++++++
src/icon.js | 2 +
src/lang/en.json | 1 +
src/pages/Details.vue | 21 +++++----
test/backend-test/test-cert-hostname-match.js | 47 +++++++++++++++++++
6 files changed, 89 insertions(+), 10 deletions(-)
create mode 100644 test/backend-test/test-cert-hostname-match.js
diff --git a/server/model/monitor.js b/server/model/monitor.js
index 2ea2f958c..b2ea982c9 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -9,7 +9,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius,
- kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
+ kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname
} = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
@@ -565,6 +565,7 @@ class Monitor extends BeanModel {
tlsSocket.once("secureConnect", async () => {
tlsInfo = checkCertificate(tlsSocket);
tlsInfo.valid = tlsSocket.authorized || false;
+ tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(tlsInfo.certInfo.raw, this.getUrl()?.hostname);
await this.handleTlsInfo(tlsInfo);
});
@@ -587,6 +588,7 @@ class Monitor extends BeanModel {
if (tlsSocket) {
tlsInfo = checkCertificate(tlsSocket);
tlsInfo.valid = tlsSocket.authorized || false;
+ tlsInfo.hostnameMatchMonitorUrl = checkCertificateHostname(tlsInfo.certInfo.raw, this.getUrl()?.hostname);
await this.handleTlsInfo(tlsInfo);
}
diff --git a/server/util-server.js b/server/util-server.js
index 250f897ba..80110399c 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -636,6 +636,30 @@ exports.checkCertificate = function (socket) {
};
};
+/**
+ * Checks if the certificate is valid for the provided hostname.
+ * Defaults to true if feature `X509Certificate` is not available, or input is not valid.
+ * @param {Buffer} certBuffer - The certificate buffer.
+ * @param {string} hostname - The hostname to compare against.
+ * @returns {boolean} True if the certificate is valid for the provided hostname, false otherwise.
+ */
+exports.checkCertificateHostname = function (certBuffer, hostname) {
+ let X509Certificate;
+ try {
+ X509Certificate = require("node:crypto").X509Certificate;
+ } catch (_) {
+ // X509Certificate is not available in this version of Node.js
+ return true;
+ }
+
+ if (!X509Certificate || !certBuffer || !hostname) {
+ return true;
+ }
+
+ let certObject = new X509Certificate(certBuffer);
+ return certObject.checkHost(hostname) !== undefined;
+};
+
/**
* Check if the provided status code is within the accepted ranges
* @param {number} status The status code to check
diff --git a/src/icon.js b/src/icon.js
index fc50c8a1e..882c0b56c 100644
--- a/src/icon.js
+++ b/src/icon.js
@@ -12,6 +12,7 @@ import {
faArrowUp,
faCog,
faEdit,
+ faExclamationTriangle,
faEye,
faEyeSlash,
faList,
@@ -60,6 +61,7 @@ library.add(
faArrowUp,
faCog,
faEdit,
+ faExclamationTriangle,
faEye,
faEyeSlash,
faList,
diff --git a/src/lang/en.json b/src/lang/en.json
index 968b44336..0d076db00 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -445,6 +445,7 @@
"Query": "Query",
"settingsCertificateExpiry": "TLS Certificate Expiry",
"certificationExpiryDescription": "HTTPS Monitors trigger notification when TLS certificate expires in:",
+ "certHostnameMismatch": "Certificate hostname does not match the monitor URL.",
"Setup Docker Host": "Set Up Docker Host",
"Connection Type": "Connection Type",
"Docker Daemon": "Docker Daemon",
diff --git a/src/pages/Details.vue b/src/pages/Details.vue
index db0a890d0..ca46c19fa 100644
--- a/src/pages/Details.vue
+++ b/src/pages/Details.vue
@@ -294,15 +294,8 @@
/>)
- {{ tlsInfo.certInfo.daysRemaining }}
- {{
- $tc("day", tlsInfo.certInfo.daysRemaining)
- }}
+ {{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }}
+
@@ -1140,4 +1133,14 @@ table {
opacity: 0.7;
}
}
+
+.cert-info-warn {
+ margin-left: 4px;
+ opacity: 0.5;
+
+ .dark & {
+ opacity: 0.7;
+ }
+}
+
diff --git a/test/backend-test/test-cert-hostname-match.js b/test/backend-test/test-cert-hostname-match.js
new file mode 100644
index 000000000..fb7c822ed
--- /dev/null
+++ b/test/backend-test/test-cert-hostname-match.js
@@ -0,0 +1,47 @@
+const { test } = require("node:test");
+
+const assert = require("node:assert");
+
+const { checkCertificateHostname } = require("../../server/util-server");
+
+const testCert = `
+-----BEGIN CERTIFICATE-----
+MIIFCTCCA/GgAwIBAgISBEROD0/r+BjpW4TvWCcZYxjpMA0GCSqGSIb3DQEBCwUA
+MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
+EwJSMzAeFw0yMzA5MDQxMjExMThaFw0yMzEyMDMxMjExMTdaMBQxEjAQBgNVBAMM
+CSouZWZmLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALywpmHr
+GOFlhw9CcW11fVloL6dceeUexbIwVd/gOt0/rIlgBViOGCh1pFYA/Essty4vXBzx
+cp6W4WurmwU6ZOJA0/T6rxnmsjxSdrHVGBGgW18HJ9IWqBl9MigjpRo9h4SlAPJq
+cAsiBfPhQ0oSe/8IqwgKA4HTvlcTf5/HKnbe0MyQt7WNILWHm+zpfLE0AmLVXxqA
+MNc/ynQDLTsWDZnqqri4MKOW1yOAMbUoAWSsNaagoGnZU4bg8uhu/2JTi/vdjl0g
+fTDOjsELc70cWekZ9Mv4ND4w3SEthotbMCCtZE5bUqcGzSm4pQEJ37kQ7xjJ0onT
+RRcuZI6/jDWzwZ0CAwEAAaOCAjUwggIxMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUE
+FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU
+hTqVTd8TZ2pknzGJtKw2JaIrPJAwHwYDVR0jBBgwFoAUFC6zF7dYVsuuUAlA5h+v
+nYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vcjMuby5s
+ZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5pLmxlbmNyLm9yZy8wPwYD
+VR0RBDgwNoIJKi5lZmYub3JnghEqLnN0YWdpbmcuZWZmLm9yZ4IWd3d3Lmh0dHBz
+LXJ1bGVzZXRzLm9yZzATBgNVHSAEDDAKMAgGBmeBDAECATCCAQMGCisGAQQB1nkC
+BAIEgfQEgfEA7wB2ALc++yTfnE26dfI5xbpY9Gxd/ELPep81xJ4dCYEl7bSZAAAB
+imBRp0EAAAQDAEcwRQIhAMW3HZwWZWXPWfahH2pr/lxCcoSluHv2huAW6rlzU3zn
+AiAOzD/p8F3gT1bzDgdSW+X5WDBeU+EutRbHMSV+Cx0mZwB1AHoyjFTYty22IOo4
+4FIe6YQWcDIThU070ivBOlejUutSAAABimBRqRQAAAQDAEYwRAIgFXvRRZS3xx83
+XdTsnto5SxSnGi1+YfzYobMdV1yqHGACIDurLvkt58TwifUbyXflGZJmOMhcC2G1
+KUd29yCUjIahMA0GCSqGSIb3DQEBCwUAA4IBAQA6t2F3PKMLlb2A/JsQhPFUJLS3
+6cx+97dzROQLBdnUQIMxPkJBN/lltNdsVxJa4A3DMbrJOayefX2l8UIvFiEFVseF
+WrxbmXDF68fwhBKBgeqZ25/S8jEdP5PWYWXHgXvx0zRdhfe9vuba5WeFyz79cR7K
+t3bSyv6GMJ2z3qBkVFGHSeYakcxPWes3CNmGxInwZNBXA2oc7xuncFrjno/USzUI
+nEefDfF3H3jC+0iP3IpsK8orwgWz4lOkcMYdan733lSZuVJ6pm7C9phTV04NGF3H
+iPenGDCg1awOyRnvxNq1MtMDkR9AHwksukzwiYNexYjyvE2t0UzXhFXwazQ3
+-----END CERTIFICATE-----
+`;
+
+test("Certificate and hostname match", () => {
+ const result = checkCertificateHostname(testCert, "www.eff.org");
+ assert.strictEqual(result, true);
+});
+
+test("Certificate and hostname mismatch", () => {
+ const result = checkCertificateHostname(testCert, "example.com");
+ assert.strictEqual(result, false);
+});