From 766d8475614fc33b2d503d5405a6cfe9dc69e52e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 May 2026 10:27:41 -0700 Subject: [PATCH] tighten validation, replace negative type checks with allowed list --- client/src/Validation/monitor.ts | 5 +++-- server/src/service/business/monitorService.ts | 17 ++++++----------- server/src/types/monitor.ts | 13 +++++++++++++ server/src/validation/shared.ts | 5 +++-- .../unit/validation/monitorValidation.test.ts | 12 ++++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index ede0a44b3..108aa729e 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -124,9 +124,10 @@ const websocketSchema = baseSchema.extend({ }); // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, -// no scheme, port, path, or whitespace. Total length ≤ 253. +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. const hostnameRegex = - /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; // DNS monitor schema const dnsSchema = baseSchema.extend({ diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 947a971fe..72c8f1fe8 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -9,7 +9,8 @@ import type { GamesMap, GroupedGeoCheckResult, } from "@/types/monitor.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; +import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js"; import type { GeoContinent } from "@/types/geoCheck.js"; import type { IChecksRepository, @@ -28,6 +29,9 @@ import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "MonitorService"; type DateRangeKey = "recent" | "day" | "week" | "month" | "all"; +const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => + supportsUptimeDetails(result.monitorType); + export interface IMonitorService { readonly serviceName: string; @@ -216,16 +220,7 @@ export class MonitorService implements IMonitorService { }); const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); - if ( - checksData.monitorType !== "http" && - checksData.monitorType !== "ping" && - checksData.monitorType !== "docker" && - checksData.monitorType !== "port" && - checksData.monitorType !== "game" && - checksData.monitorType !== "grpc" && - checksData.monitorType !== "websocket" && - checksData.monitorType !== "dns" - ) { + if (!isUptimeChecksResult(checksData)) { throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 09797f2ca..62b3a367e 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -9,6 +9,19 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const UptimeDetailsSupportedTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const satisfies readonly MonitorType[]; +export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number]; +export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type); + export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 05ecd9726..99f92f250 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -21,9 +21,10 @@ export const dnsServerValidation = z .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 +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// 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 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; diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 87ac58b6d..62fa91625 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -75,6 +75,18 @@ describe("monitorValidation — DNS fields", () => { ).toThrow(); }); + it("accepts service labels with leading underscore (DMARC, SRV, ACME)", () => { + for (const url of ["_dmarc.example.com", "_imaps._tcp.example.com", "_acme-challenge.example.com"]) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + url, + dnsServer: "8.8.8.8", + dnsRecordType: "TXT", + }); + expect(parsed.url).toBe(url); + } + }); + it("does not enforce the hostname rule for non-DNS monitors", () => { const parsed = createMonitorBodyValidation.parse({ name: "HTTP check",