Add SSL certificate checker and tests
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// Import Express so we can create the SSL route.
|
||||
const express = require("express");
|
||||
|
||||
// Import hostname validation.
|
||||
const {
|
||||
validateHostname,
|
||||
} = require("../middleware/validateHostname");
|
||||
|
||||
// Import the SSL certificate service.
|
||||
const {
|
||||
checkSslCertificate,
|
||||
} = require("../services/sslService");
|
||||
|
||||
// Reuse the existing request rate limiter.
|
||||
const {
|
||||
uptimeRateLimiter,
|
||||
} = require("../middleware/rateLimit");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/ssl
|
||||
router.post("/", uptimeRateLimiter, async (req, res) => {
|
||||
try {
|
||||
// Validate and normalize the hostname or pasted URL.
|
||||
const hostname = validateHostname(req.body.hostname);
|
||||
|
||||
// Inspect the public TLS certificate.
|
||||
const result = await checkSslCertificate(hostname);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
error.message ||
|
||||
"The SSL certificate could not be checked.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,6 +17,9 @@ const app = express();
|
||||
// Otherwise, use port 3001 for local development.
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Import the SSL certificate API route.
|
||||
const sslRouter = require("./routes/ssl");
|
||||
|
||||
// Allow the server to read JSON request bodies.
|
||||
app.use(express.json());
|
||||
|
||||
@@ -29,6 +32,9 @@ app.use("/api/uptime", uptimeRouter);
|
||||
// Handle DNS lookup requests.
|
||||
app.use("/api/dns", dnsRouter);
|
||||
|
||||
// Handle SSL certificate checks.
|
||||
app.use("/api/ssl", sslRouter);
|
||||
|
||||
// Simple health-check route.
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Import Node's built-in TLS module for secure connections.
|
||||
const tls = require("tls");
|
||||
|
||||
// Import DNS promises so we can resolve the hostname ourselves.
|
||||
const dns = require("dns").promises;
|
||||
|
||||
// Reuse the IP security check from the website status checker.
|
||||
const { isPrivateIp } = require("./urlSecurityService");
|
||||
|
||||
// Maximum amount of time allowed for the TLS connection.
|
||||
const TLS_TIMEOUT_MS = 5000;
|
||||
|
||||
// Convert certificate subject or issuer objects into readable text.
|
||||
function formatCertificateName(name) {
|
||||
// Some servers may not provide all certificate fields.
|
||||
if (!name || typeof name !== "object") {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Keep the most useful certificate identity fields.
|
||||
const fields = [
|
||||
["CN", name.CN],
|
||||
["O", name.O],
|
||||
["OU", name.OU],
|
||||
["C", name.C],
|
||||
["ST", name.ST],
|
||||
["L", name.L],
|
||||
];
|
||||
|
||||
return fields
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => `${label}=${value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// Resolve a hostname and choose a public address.
|
||||
async function resolvePublicAddress(hostname) {
|
||||
let addresses;
|
||||
|
||||
try {
|
||||
// Resolve every IPv4 and IPv6 address for the hostname.
|
||||
addresses = await dns.lookup(hostname, {
|
||||
all: true,
|
||||
verbatim: true,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The hostname could not be resolved.");
|
||||
}
|
||||
|
||||
// Keep only addresses that are safe to contact.
|
||||
const publicAddresses = addresses.filter(
|
||||
(result) => !isPrivateIp(result.address)
|
||||
);
|
||||
|
||||
if (publicAddresses.length === 0) {
|
||||
throw new Error(
|
||||
"The hostname does not resolve to a public IP address."
|
||||
);
|
||||
}
|
||||
|
||||
// Use the first safe resolved address.
|
||||
return publicAddresses[0];
|
||||
}
|
||||
|
||||
// Calculate the number of whole days until certificate expiration.
|
||||
function calculateDaysRemaining(validTo) {
|
||||
const expirationTime = new Date(validTo).getTime();
|
||||
|
||||
if (Number.isNaN(expirationTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const differenceMs = expirationTime - Date.now();
|
||||
|
||||
return Math.ceil(differenceMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Inspect the TLS certificate for a public hostname.
|
||||
async function checkSslCertificate(hostname) {
|
||||
const resolvedAddress = await resolvePublicAddress(hostname);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let finished = false;
|
||||
|
||||
// Finish once and always close the socket.
|
||||
function finish(callback, value, socket) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
socket.destroy();
|
||||
callback(value);
|
||||
}
|
||||
|
||||
// Connect directly to the resolved public IP.
|
||||
// servername preserves SNI and hostname certificate checking.
|
||||
const socket = tls.connect({
|
||||
host: resolvedAddress.address,
|
||||
port: 443,
|
||||
servername: hostname,
|
||||
|
||||
// Keep the connection available even when the certificate is invalid.
|
||||
// We report the authorization error instead of hiding the certificate.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
socket.setTimeout(TLS_TIMEOUT_MS);
|
||||
|
||||
socket.once("secureConnect", () => {
|
||||
const certificate = socket.getPeerCertificate(true);
|
||||
|
||||
if (!certificate || Object.keys(certificate).length === 0) {
|
||||
finish(
|
||||
reject,
|
||||
new Error("The server did not provide a certificate."),
|
||||
socket
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cipher = socket.getCipher();
|
||||
const daysRemaining = calculateDaysRemaining(certificate.valid_to);
|
||||
|
||||
const result = {
|
||||
hostname,
|
||||
resolvedAddress: resolvedAddress.address,
|
||||
addressFamily: resolvedAddress.family,
|
||||
|
||||
authorized: socket.authorized,
|
||||
authorizationError: socket.authorizationError || null,
|
||||
|
||||
subject: formatCertificateName(certificate.subject),
|
||||
issuer: formatCertificateName(certificate.issuer),
|
||||
|
||||
validFrom: certificate.valid_from,
|
||||
validTo: certificate.valid_to,
|
||||
daysRemaining,
|
||||
|
||||
serialNumber: certificate.serialNumber || null,
|
||||
fingerprint256: certificate.fingerprint256 || null,
|
||||
|
||||
protocol: socket.getProtocol() || null,
|
||||
cipher: cipher ? cipher.name : null,
|
||||
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
finish(resolve, result, socket);
|
||||
});
|
||||
|
||||
socket.once("timeout", () => {
|
||||
finish(
|
||||
reject,
|
||||
new Error("The TLS connection timed out."),
|
||||
socket
|
||||
);
|
||||
});
|
||||
|
||||
socket.once("error", (error) => {
|
||||
const safeMessage =
|
||||
error.code === "ECONNREFUSED"
|
||||
? "The server refused the TLS connection."
|
||||
: "The TLS connection could not be completed.";
|
||||
|
||||
finish(reject, new Error(safeMessage), socket);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkSslCertificate,
|
||||
resolvePublicAddress,
|
||||
calculateDaysRemaining,
|
||||
formatCertificateName,
|
||||
};
|
||||
Reference in New Issue
Block a user