diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server-deployment.yaml index 8548495dd..6f21f86f3 100644 --- a/charts/helm/checkmate/templates/server-deployment.yaml +++ b/charts/helm/checkmate/templates/server-deployment.yaml @@ -20,11 +20,3 @@ spec: envFrom: - secretRef: name: checkmate-secrets - volumeMounts: - - name: docker-sock - mountPath: /var/run/docker.sock - volumes: - - name: docker-sock - hostPath: - path: /var/run/docker.sock - type: Socket diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/server-ingress.yaml index acf64f95f..c1982d451 100644 --- a/charts/helm/checkmate/templates/server-ingress.yaml +++ b/charts/helm/checkmate/templates/server-ingress.yaml @@ -5,6 +5,11 @@ metadata: name: server-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" spec: rules: - host: {{ .Values.server.ingress.host }} diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 54bb239a4..f1258091b 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -977,7 +977,7 @@ class NetworkService { }); } - return this.axiosInstance.get(`/monitors/summary/team?${params.toString()}`, { + return this.axiosInstance.get(`/monitors/team/summary?${params.toString()}`, { headers: { "Content-Type": "application/json", }, diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index b2fe7de71..94575ce99 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -128,48 +128,37 @@ const monitorValidation = joi.object({ .string() .trim() .custom((value, helpers) => { - // Regex from https://gist.github.com/dperini/729294 - var urlRegex = new RegExp( - "^" + - // protocol identifier (optional) - // short syntax // still required - "(?:(?:https?|ftp):\\/\\/)?" + - // user:pass BasicAuth (optional) - "(?:" + - // IP address dotted notation octets - // excludes loopback network 0.0.0.0 - // excludes reserved space >= 224.0.0.0 - // excludes network & broadcast addresses - // (first & last IP address of each class) - "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + - "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + - "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + - "|" + - // host & domain names, may end with dot - // can be replaced by a shortest alternative - // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+ - "(?:" + - "(?:" + - "[a-z0-9\\u00a1-\\uffff]" + - "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + - ")?" + - "[a-z0-9\\u00a1-\\uffff]\\." + - ")+" + - // TLD identifier name, may end with dot - "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + - ")" + - // port number (optional) - "(?::\\d{2,5})?" + - // resource path (optional) - "(?:[/?#]\\S*)?" + - "$", - "i" - ); - if (!urlRegex.test(value)) { + // 1. Standard URLs: must have protocol and pass canParse() + if (/^(https?:\/\/)/.test(value)) { + if ( + typeof URL !== "undefined" && + typeof URL.canParse === "function" && + URL.canParse(value) + ) { + return value; + } + // else, it's a malformed URL with protocol return helpers.error("string.invalidUrl"); } - return value; + // 2. Docker/internal hostnames (no protocol) + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { + if (value.includes("..")) { + return helpers.error("string.invalidUrl"); + } + // Split by dot, check each label + const labels = value.split(":")[0].split("."); + for (const label of labels) { + if (!label) return helpers.error("string.invalidUrl"); + if (label.startsWith("-") || label.endsWith("-")) { + return helpers.error("string.invalidUrl"); + } + } + return value; + } + + // 3. Everything else is invalid + return helpers.error("string.invalidUrl"); }) .messages({ "string.empty": "This field is required.", diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 1cc1a747a..ace63b662 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -340,7 +340,6 @@ class AuthController { if (!email) { throw new Error("No email in request"); } - // 1. Find all the monitors associated with the team ID if superadmin const teamId = req?.user?.teamId; const userId = req?.user?._id; @@ -354,7 +353,11 @@ class AuthController { } const roles = req.user.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + // 1. Find all the monitors associated with the team ID if superadmin const result = await this.db.getMonitorsByTeamId({ teamId: teamId, }); diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 79579e521..9943a765a 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -20,6 +20,7 @@ import axios from "axios"; import seedDb from "../db/mongo/utils/seedDb.js"; import pkg from "papaparse"; import { asyncHandler, createServerError } from "../utils/errorUtils.js"; +import { fetchMonitorCertificate } from "./controllerUtils.js"; const SERVICE_NAME = "monitorController"; class MonitorController { @@ -31,6 +32,15 @@ class MonitorController { this.emailService = emailService; } + async verifyTeamAccess(teamId, monitorId) { + const monitor = await this.db.getMonitorById(monitorId); + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + } + /** * Returns all monitors * @async @@ -52,32 +62,18 @@ class MonitorController { "getAllMonitors" ); - /** - * Returns all monitors with uptime stats for 1,7,30, and 90 days - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @param {function} next - * @returns {Promise} - * @throws {Error} - */ - getAllMonitorsWithUptimeStats = asyncHandler( - async (req, res, next) => { - const monitors = await this.db.getAllMonitorsWithUptimeStats(); - return res.success({ - msg: this.stringService.monitorGetAll, - data: monitors, - }); - }, - SERVICE_NAME, - "getAllMonitorsWithUptimeStats" - ); - getUptimeDetailsById = asyncHandler( async (req, res, next) => { const { monitorId } = req.params; const { dateRange, normalize } = req.query; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const data = await this.db.getUptimeDetailsById({ monitorId, dateRange, @@ -109,6 +105,13 @@ class MonitorController { let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; const { monitorId } = req.params; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitorStats = await this.db.getMonitorStatsById({ monitorId, limit, @@ -140,8 +143,14 @@ class MonitorController { await getHardwareDetailsByIdParamValidation.validateAsync(req.params); await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); - const { monitorId } = req.params; - const { dateRange } = req.query; + const monitorId = req?.params?.monitorId; + const dateRange = req?.query?.dateRange; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange }); return res.success({ msg: this.stringService.monitorGetByIdSuccess, @@ -153,7 +162,7 @@ class MonitorController { ); getMonitorCertificate = asyncHandler( - async (req, res, next, fetchMonitorCertificate) => { + async (req, res, next) => { await getCertificateParamValidation.validateAsync(req.params); const { monitorId } = req.params; @@ -188,6 +197,18 @@ class MonitorController { await getMonitorByIdQueryValidation.validateAsync(req.query); const monitor = await this.db.getMonitorById(req.params.monitorId); + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + return res.success({ msg: this.stringService.monitorGetByIdSuccess, data: monitor, @@ -367,6 +388,13 @@ class MonitorController { async (req, res, next) => { await getMonitorByIdParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitor = await this.db.deleteMonitor({ monitorId }); await this.jobQueue.deleteJob(monitor); await this.db.deleteStatusPagesByMonitorId(monitor._id); @@ -389,7 +417,7 @@ class MonitorController { */ deleteAllMonitors = asyncHandler( async (req, res, next) => { - const { teamId } = req.user; + const teamId = req?.user?.teamId; const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); await Promise.all( monitors.map(async (monitor) => { @@ -431,7 +459,14 @@ class MonitorController { async (req, res, next) => { await getMonitorByIdParamValidation.validateAsync(req.params); await editMonitorBodyValidation.validateAsync(req.body); - const { monitorId } = req.params; + const monitorId = req?.params?.monitorId; + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); const editedMonitor = await this.db.editMonitor(monitorId, req.body); @@ -462,6 +497,13 @@ class MonitorController { await pauseMonitorParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitor = await this.db.pauseMonitor({ monitorId }); monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 4e21e7284..41af21ece 100755 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -39,9 +39,19 @@ class NotificationController { }); const body = req.body; - const { _id, teamId } = req.user; - body.userId = _id; + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const userId = req?.user?._id; + if (!userId) { + throw new Error("No user ID in request"); + } + body.userId = userId; body.teamId = teamId; + const notification = await this.db.createNotification(body); return res.success({ msg: "Notification created successfully", @@ -54,7 +64,13 @@ class NotificationController { getNotificationsByTeamId = asyncHandler( async (req, res, next) => { - const notifications = await this.db.getNotificationsByTeamId(req.user.teamId); + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notifications = await this.db.getNotificationsByTeamId(teamId); + return res.success({ msg: "Notifications fetched successfully", data: notifications, @@ -66,6 +82,18 @@ class NotificationController { deleteNotification = asyncHandler( async (req, res, next) => { + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notification = await this.db.getNotificationById(req.params.id); + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + await this.db.deleteNotificationById(req.params.id); return res.success({ msg: "Notification deleted successfully", @@ -78,6 +106,17 @@ class NotificationController { getNotificationById = asyncHandler( async (req, res, next) => { const notification = await this.db.getNotificationById(req.params.id); + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } return res.success({ msg: "Notification fetched successfully", data: notification, @@ -93,10 +132,23 @@ class NotificationController { abortEarly: false, }); - const notification = await this.db.editNotification(req.params.id, req.body); + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notification = await this.db.getNotificationById(req.params.id); + + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + + const editedNotification = await this.db.editNotification(req.params.id, req.body); return res.success({ msg: "Notification updated successfully", - data: notification, + data: editedNotification, }); }, SERVICE_NAME, @@ -105,8 +157,20 @@ class NotificationController { testAllNotifications = asyncHandler( async (req, res, next) => { - const { monitorId } = req.body; + const monitorId = req.body.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + const notifications = monitor.notifications; if (notifications.length === 0) throw new Error("No notifications"); const result = await this.notificationService.testAllNotifications(notifications); diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index b331bc337..3192b711c 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -56,53 +56,6 @@ const getAllMonitors = async (req, res) => { } }; -/** - * Get all monitors with uptime stats for 1,7,30, and 90 days - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise>} - * @throws {Error} - */ -const getAllMonitorsWithUptimeStats = async () => { - const timeRanges = { - 1: new Date(Date.now() - 24 * 60 * 60 * 1000), - 7: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - 30: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - 90: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), - }; - - try { - const monitors = await Monitor.find(); - const monitorsWithStats = await Promise.all( - monitors.map(async (monitor) => { - const model = CHECK_MODEL_LOOKUP[monitor.type]; - - const uptimeStats = await Promise.all( - Object.entries(timeRanges).map(async ([days, startDate]) => { - const checks = await model.find({ - monitorId: monitor._id, - createdAt: { $gte: startDate }, - }); - return [days, getUptimePercentage(checks)]; - }) - ); - - return { - ...monitor.toObject(), - ...Object.fromEntries(uptimeStats), - }; - }) - ); - - return monitorsWithStats; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getAllMonitorsWithUptimeStats"; - throw error; - } -}; - /** * Function to calculate uptime duration based on the most recent check. * @param {Array} checks Array of check objects. @@ -761,7 +714,6 @@ const pauseMonitor = async ({ monitorId }) => { export { getAllMonitors, - getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, getMonitorsByIds, diff --git a/server/db/mongo/modules/notificationModule.js b/server/db/mongo/modules/notificationModule.js index 08d57b1a9..2348a6321 100755 --- a/server/db/mongo/modules/notificationModule.js +++ b/server/db/mongo/modules/notificationModule.js @@ -74,6 +74,11 @@ const deleteNotificationsByMonitorId = async (monitorId) => { const deleteNotificationById = async (id) => { try { + const notification = await Notification.findById(id); + if (!notification) { + throw new Error("Notification not found"); + } + const result = await Notification.findByIdAndDelete(id); await Monitor.updateMany({ notifications: id }, { $pull: { notifications: id } }); return result; diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 2911bf9d7..15553fc1c 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -20,10 +20,9 @@ class MonitorRoutes { // Team routes this.router.get("/team", this.monitorController.getMonitorsByTeamId); this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); - this.router.get("/summary/team", this.monitorController.getMonitorsAndSummaryByTeamId); // TODO should be /team/summary + this.router.get("/team/summary", this.monitorController.getMonitorsAndSummaryByTeamId); // Uptime routes - this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById); // Hardware routes @@ -53,13 +52,8 @@ class MonitorRoutes { // Individual monitor CRUD routes this.router.get("/:monitorId", this.monitorController.getMonitorById); - this.router.put("/:monitorId", verifyTeamAccess(Monitor, "monitorId"), isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); - this.router.delete( - "/:monitorId", - verifyOwnership(Monitor, "monitorId"), - isAllowed(["admin", "superadmin"]), - this.monitorController.deleteMonitor - ); + this.router.put("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); + this.router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.deleteMonitor); } getRouter() { diff --git a/server/routes/notificationRoute.js b/server/routes/notificationRoute.js index 6c77d8519..f88b0b96c 100755 --- a/server/routes/notificationRoute.js +++ b/server/routes/notificationRoute.js @@ -1,8 +1,4 @@ import { Router } from "express"; -import { verifyJWT } from "../middleware/verifyJWT.js"; -import { verifyOwnership } from "../middleware/verifyOwnership.js"; -import { verifyTeamAccess } from "../middleware/verifyTeamAccess.js"; -import Notification from "../db/models/Notification.js"; class NotificationRoutes { constructor(notificationController) { this.router = Router(); @@ -11,8 +7,6 @@ class NotificationRoutes { } initializeRoutes() { - this.router.use(verifyJWT); - this.router.post("/", this.notificationController.createNotification); this.router.post("/test/all", this.notificationController.testAllNotifications); @@ -20,9 +14,9 @@ class NotificationRoutes { this.router.get("/team", this.notificationController.getNotificationsByTeamId); - this.router.delete("/:id", verifyOwnership(Notification, "id"), this.notificationController.deleteNotification); this.router.get("/:id", this.notificationController.getNotificationById); - this.router.put("/:id", verifyTeamAccess(Notification, "id"), this.notificationController.editNotification); + this.router.delete("/:id", this.notificationController.deleteNotification); + this.router.put("/:id", this.notificationController.editNotification); } getRouter() { diff --git a/server/validation/joi.js b/server/validation/joi.js index ec2274596..57c8b7350 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -158,7 +158,48 @@ const createMonitorBodyValidation = joi.object({ name: joi.string().required(), description: joi.string().required(), type: joi.string().required(), - url: joi.string().required(), + url: joi + .string() + .required() + .custom((value, helpers) => { + // 1. Standard URLs: must have protocol and pass canParse() + if (/^(https?:\/\/)/.test(value)) { + if ( + typeof URL !== "undefined" && + typeof URL.canParse === "function" && + URL.canParse(value) + ) { + return value; + } + // else, it's a malformed URL with protocol + return helpers.error("string.invalidUrl"); + } + + // 2. Docker/internal hostnames (no protocol) + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { + if (value.includes("..")) { + return helpers.error("string.invalidUrl"); + } + // Split by dot, check each label + const labels = value.split(":")[0].split("."); + for (const label of labels) { + if (!label) return helpers.error("string.invalidUrl"); + if (label.startsWith("-") || label.endsWith("-")) { + return helpers.error("string.invalidUrl"); + } + } + return value; + } + + // 3. Everything else is invalid + return helpers.error("string.invalidUrl"); + }) + .messages({ + "string.empty": "This field is required.", + "string.uri": "The URL you provided is not valid.", + "string.invalidUrl": + "Please enter a valid URL, hostname, or container name (with optional port).", + }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), isActive: joi.boolean(),