Initial ByteGeist Toolbox uptime checker
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// Import the rate-limiting middleware.
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
// Limit how often one IP address can use the uptime checker.
|
||||
const uptimeRateLimiter = rateLimit({
|
||||
// Count requests inside a 15-minute window.
|
||||
windowMs: 15 * 60 * 1000,
|
||||
|
||||
// Allow 30 checks per IP during that window.
|
||||
limit: 30,
|
||||
|
||||
// Return modern rate-limit headers.
|
||||
standardHeaders: true,
|
||||
|
||||
// Disable older legacy headers.
|
||||
legacyHeaders: false,
|
||||
|
||||
// Return a clear JSON message when the limit is reached.
|
||||
message: {
|
||||
error: "Too many website checks. Please try again later.",
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
uptimeRateLimiter,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const express = require("express");
|
||||
const { checkWebsite } = require("../services/uptimeService");
|
||||
const { uptimeRateLimiter } = require("../middleware/rateLimit");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/uptime
|
||||
router.post("/", uptimeRateLimiter, async (req, res) => {
|
||||
const { url } = req.body;
|
||||
|
||||
// Confirm that a URL was submitted as text.
|
||||
if (typeof url !== "string" || url.trim() === "") {
|
||||
return res.status(400).json({
|
||||
error: "A URL is required.",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent excessively long input.
|
||||
if (url.length > 2048) {
|
||||
return res.status(400).json({
|
||||
error: "The URL is too long.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await checkWebsite(url.trim());
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,38 @@
|
||||
// Import Express so we can create the web server.
|
||||
const express = require("express");
|
||||
|
||||
// Import path so we can safely point Express to the public folder.
|
||||
const path = require("path");
|
||||
|
||||
// Import the uptime API route.
|
||||
const uptimeRouter = require("./routes/uptime");
|
||||
|
||||
// Create the Express application.
|
||||
const app = express();
|
||||
|
||||
// Use the PORT environment variable if one exists.
|
||||
// Otherwise, use port 3001 for local development.
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Allow the server to read JSON request bodies.
|
||||
app.use(express.json());
|
||||
|
||||
// Serve the files inside the public folder.
|
||||
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
|
||||
// Handle website uptime checks.
|
||||
app.use("/api/uptime", uptimeRouter);
|
||||
|
||||
// Simple health-check route.
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
service: "bytegeist-toolbox",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Start the web server.
|
||||
app.listen(PORT, () => {
|
||||
console.log(`ByteGeist Toolbox running at http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
const { validatePublicUrl } = require("./urlSecurityService");
|
||||
|
||||
// Maximum number of redirects the checker will follow.
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
// Maximum time allowed for each request.
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
// Check whether an HTTP status code represents a redirect.
|
||||
function isRedirectStatus(statusCode) {
|
||||
return [301, 302, 303, 307, 308].includes(statusCode);
|
||||
}
|
||||
|
||||
// Check a public website and safely follow redirects.
|
||||
async function checkWebsite(submittedUrl) {
|
||||
const startTime = Date.now();
|
||||
|
||||
let currentUrl = submittedUrl;
|
||||
let redirectCount = 0;
|
||||
|
||||
try {
|
||||
while (redirectCount <= MAX_REDIRECTS) {
|
||||
// Validate the current URL before every request.
|
||||
// This prevents redirects from reaching private systems.
|
||||
const validatedUrl = await validatePublicUrl(currentUrl);
|
||||
|
||||
const response = await fetch(validatedUrl, {
|
||||
method: "GET",
|
||||
|
||||
// We handle redirects ourselves so every destination can be checked.
|
||||
redirect: "manual",
|
||||
|
||||
// Stop waiting after five seconds.
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
|
||||
headers: {
|
||||
// Identify the service instead of pretending to be a browser.
|
||||
"User-Agent": "ByteGeist-Toolbox/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
// Check whether the response wants to redirect somewhere else.
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get("location");
|
||||
|
||||
if (!location) {
|
||||
throw new Error("The website returned an invalid redirect.");
|
||||
}
|
||||
|
||||
redirectCount += 1;
|
||||
|
||||
if (redirectCount > MAX_REDIRECTS) {
|
||||
throw new Error("The website redirected too many times.");
|
||||
}
|
||||
|
||||
// Convert relative redirects such as /login into complete URLs.
|
||||
currentUrl = new URL(location, validatedUrl).toString();
|
||||
|
||||
// Continue the loop and validate the redirect destination.
|
||||
continue;
|
||||
}
|
||||
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
// Cancel the response body because we only need status information.
|
||||
// This avoids downloading an entire website.
|
||||
if (response.body) {
|
||||
await response.body.cancel();
|
||||
}
|
||||
|
||||
return {
|
||||
online: true,
|
||||
statusCode: response.status,
|
||||
responseTimeMs,
|
||||
finalUrl: validatedUrl.toString(),
|
||||
redirectCount,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("The website redirected too many times.");
|
||||
} catch (error) {
|
||||
if (error.name === "TimeoutError") {
|
||||
return {
|
||||
online: false,
|
||||
error: "The website took too long to respond.",
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
online: false,
|
||||
error: error.message || "The website could not be reached.",
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebsite,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
// Import DNS tools so we can resolve domain names before requesting them.
|
||||
const dns = require("dns").promises;
|
||||
|
||||
// Import Node's IP address checker.
|
||||
const net = require("net");
|
||||
|
||||
// Import ipaddr.js so we can classify IPv4 and IPv6 addresses.
|
||||
const ipaddr = require("ipaddr.js");
|
||||
|
||||
// Check whether an IP address belongs to a blocked private or local range.
|
||||
function isPrivateIp(ipAddress) {
|
||||
try {
|
||||
const address = ipaddr.parse(ipAddress);
|
||||
|
||||
// ipaddr.js labels addresses with ranges such as:
|
||||
// private, loopback, linkLocal, multicast, and unspecified.
|
||||
const blockedRanges = [
|
||||
"private",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
"multicast",
|
||||
"unspecified",
|
||||
"reserved",
|
||||
"broadcast",
|
||||
"carrierGradeNat",
|
||||
];
|
||||
|
||||
return blockedRanges.includes(address.range());
|
||||
} catch {
|
||||
// An address that cannot be parsed should not be trusted.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate a URL before the server makes a request.
|
||||
async function validatePublicUrl(urlValue) {
|
||||
let parsedUrl;
|
||||
|
||||
try {
|
||||
// Convert the submitted text into a proper URL object.
|
||||
parsedUrl = new URL(urlValue);
|
||||
} catch {
|
||||
throw new Error("Enter a valid URL.");
|
||||
}
|
||||
|
||||
// Only allow normal website protocols.
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
throw new Error("Only HTTP and HTTPS URLs are allowed.");
|
||||
}
|
||||
|
||||
// Do not allow usernames or passwords inside the URL.
|
||||
if (parsedUrl.username || parsedUrl.password) {
|
||||
throw new Error("URLs containing usernames or passwords are not allowed.");
|
||||
}
|
||||
|
||||
// Initially allow only normal HTTP and HTTPS ports.
|
||||
const allowedPorts = ["", "80", "443"];
|
||||
|
||||
if (!allowedPorts.includes(parsedUrl.port)) {
|
||||
throw new Error("Only ports 80 and 443 are allowed.");
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
// Block common local hostnames.
|
||||
const blockedHostnames = [
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"host.docker.internal",
|
||||
];
|
||||
|
||||
if (
|
||||
blockedHostnames.includes(hostname) ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local")
|
||||
) {
|
||||
throw new Error("Local and private network addresses are not allowed.");
|
||||
}
|
||||
|
||||
// If the hostname is already an IP address, inspect it directly.
|
||||
if (net.isIP(hostname)) {
|
||||
if (isPrivateIp(hostname)) {
|
||||
throw new Error("Local and private network addresses are not allowed.");
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
let resolvedAddresses;
|
||||
|
||||
try {
|
||||
// Resolve every IP address associated with the hostname.
|
||||
resolvedAddresses = await dns.lookup(hostname, {
|
||||
all: true,
|
||||
verbatim: true,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The hostname could not be resolved.");
|
||||
}
|
||||
|
||||
if (resolvedAddresses.length === 0) {
|
||||
throw new Error("The hostname did not resolve to an IP address.");
|
||||
}
|
||||
|
||||
// Reject the hostname if any resolved address is private or local.
|
||||
for (const result of resolvedAddresses) {
|
||||
if (isPrivateIp(result.address)) {
|
||||
throw new Error("Local and private network addresses are not allowed.");
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validatePublicUrl,
|
||||
isPrivateIp,
|
||||
};
|
||||
Reference in New Issue
Block a user