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); +});