diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 53ea095f1..6d0d37eb5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -572,7 +572,7 @@ "placeholder": "8.8.8.8" }, "dnsRecordType": { - "label": "DNS Record Type", + "label": "DNS record type", "value": { "A": "A", "AAAA": "AAAA", diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts index 64e473e88..92b4eb333 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -16,11 +16,19 @@ export class DNSProvider implements IStatusProvider { async handle(monitor: Monitor): Promise> { try { - const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor; + const { url, dnsServer, dnsRecordType = "A" } = monitor; if (!dnsServer) { throw new Error("DNS server is required for DNS monitoring"); } + let hostname: string; + try { + const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing + hostname = new URL(withScheme).hostname; + } catch { + hostname = url; + } + const resolver = this.createResolver(); resolver.setServers([dnsServer]); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 8a4a7938e..0c58b9df1 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; +import { MonitorTypes } from "@/types/monitor.js"; //**************************************** // Check Validations @@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns"]).optional(), + type: z.enum(MonitorTypes).optional(), sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index beb990f71..13c65cd4c 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion, dnsServerValidation } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js"; @@ -39,69 +39,83 @@ export const getCertificateParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); -export const createMonitorBodyValidation = z.object({ - _id: z.string().optional(), - name: z.string().min(1, "Name is required"), - description: z.union([z.string(), z.literal("")]).optional(), - type: z.enum(MonitorTypes, "Invalid monitor type"), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - url: z.string().min(1, "URL is required"), - ignoreTlsErrors: z.boolean().default(false), - useAdvancedMatching: z.boolean().default(false), - port: z.number().optional(), - isActive: z.boolean().optional(), - interval: z.number().optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), -}); +const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => { + if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "Enter a valid domain (e.g. www.example.com)", + }); + } +}; -export const editMonitorBodyValidation = z.object({ - name: z.string().optional(), - type: z.enum(MonitorTypes).optional(), - url: z.string().optional(), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - description: z.union([z.string(), z.literal("")]).optional(), - interval: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - ignoreTlsErrors: z.boolean().optional(), - useAdvancedMatching: z.boolean().optional(), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - port: z.number().min(1).max(65535).optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).optional(), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), -}); +export const createMonitorBodyValidation = z + .object({ + _id: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.literal("")]).optional(), + type: z.enum(MonitorTypes, "Invalid monitor type"), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + url: z.string().min(1, "URL is required"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + port: z.number().optional(), + isActive: z.boolean().optional(), + interval: z.number().optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname); + +export const editMonitorBodyValidation = z + .object({ + name: z.string().optional(), + type: z.enum(MonitorTypes).optional(), + url: z.string().optional(), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + description: z.union([z.string(), z.literal("")]).optional(), + interval: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + ignoreTlsErrors: z.boolean().optional(), + useAdvancedMatching: z.boolean().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + port: z.number().min(1).max(65535).optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -124,49 +138,51 @@ export const getUptimeDetailsByIdQueryValidation = z.object({ normalize: booleanCoercion.optional(), }); -const importedMonitorSchema = z.object({ - id: z.string().optional(), - userId: z.string().optional(), - teamId: z.string().optional(), - name: z.string().min(1, "Name is required"), - description: z.union([z.string(), z.literal("")]).optional(), - status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"), - statusWindow: z.array(z.boolean()).default([]), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - type: z.enum(MonitorTypes, "Invalid monitor type"), - ignoreTlsErrors: z.boolean().default(false), - useAdvancedMatching: z.boolean().default(false), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - url: z.string().min(1, "URL is required"), - port: z.number().optional(), - isActive: z.boolean().default(true), - interval: z.number().default(60000), - uptimePercentage: z.number().optional(), - notifications: z.array(z.string()).default([]), - secret: z.string().optional(), - cpuAlertThreshold: z.number().default(100), - cpuAlertCounter: z.number().default(5), - memoryAlertThreshold: z.number().default(100), - memoryAlertCounter: z.number().default(5), - diskAlertThreshold: z.number().default(100), - diskAlertCounter: z.number().default(5), - tempAlertThreshold: z.number().default(100), - tempAlertCounter: z.number().default(5), - selectedDisks: z.array(z.string()).default([]), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - group: z.union([z.string().max(50).trim(), z.null()]).default(null), - geoCheckEnabled: z.boolean().default(false), - geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), - geoCheckInterval: z.number().min(300000).default(300000), - dnsServer: dnsServerValidation.optional(), - dnsRecordType: z.enum(DnsRecordTypes).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); +const importedMonitorSchema = z + .object({ + id: z.string().optional(), + userId: z.string().optional(), + teamId: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.literal("")]).optional(), + status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"), + statusWindow: z.array(z.boolean()).default([]), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + type: z.enum(MonitorTypes, "Invalid monitor type"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + url: z.string().min(1, "URL is required"), + port: z.number().optional(), + isActive: z.boolean().default(true), + interval: z.number().default(60000), + uptimePercentage: z.number().optional(), + notifications: z.array(z.string()).default([]), + secret: z.string().optional(), + cpuAlertThreshold: z.number().default(100), + cpuAlertCounter: z.number().default(5), + memoryAlertThreshold: z.number().default(100), + memoryAlertCounter: z.number().default(5), + diskAlertThreshold: z.number().default(100), + diskAlertCounter: z.number().default(5), + tempAlertThreshold: z.number().default(100), + tempAlertCounter: z.number().default(5), + selectedDisks: z.array(z.string()).default([]), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + group: z.union([z.string().max(50).trim(), z.null()]).default(null), + geoCheckEnabled: z.boolean().default(false), + geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), + geoCheckInterval: z.number().min(300000).default(300000), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .superRefine(refineDnsHostname); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 6b607cce3..05ecd9726 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -20,6 +20,11 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// no scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with +// the client-side regex in client/src/Validation/monitor.ts. +export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; if (val === "false" || val === false) return false; diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index 3fcea5113..a96731be5 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -187,6 +187,28 @@ describe("DNSProvider", () => { await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); }); + it("strips scheme from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca"); + expect(result.payload?.hostname).toBe("www.google.ca"); + }); + + it("strips port and path from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com:53/some/path" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + + it("passes a bare hostname through unchanged", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + it("stringifies non-Error throws from outside timeRequest", async () => { mockResolverInstance.setServers.mockImplementationOnce(() => { throw 42; diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 5bd178f51..87ac58b6d 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -51,6 +51,39 @@ describe("monitorValidation — DNS fields", () => { expect(parsed.dnsServer).toBeUndefined(); expect(parsed.dnsRecordType).toBeUndefined(); }); + + it("rejects a DNS monitor whose url has a scheme or path", () => { + for (const badUrl of ["https://example.com", "example.com/path", "example.com:53", " example.com"]) { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + url: badUrl, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }) + ).toThrow(); + } + }); + + it("rejects a DNS monitor with an invalid dnsServer", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "not-an-ip", + dnsRecordType: "A", + }) + ).toThrow(); + }); + + it("does not enforce the hostname rule for non-DNS monitors", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com/some/path", + }); + + expect(parsed.url).toBe("https://example.com/some/path"); + }); }); describe("editMonitorBodyValidation", () => {