tighten validation, add tests

This commit is contained in:
Alex committed 2026-05-11 10:12:27 -07:00
1 parent 272e4b5014
commit 08043a5ea0
7 files changed
+194 -109

No files matched your search

+1 -1
View File
@@ -572,7 +572,7 @@
"placeholder": "8.8.8.8"
},
"dnsRecordType": {
"label": "DNS Record Type",
"label": "DNS record type",
"value": {
"A": "A",
"AAAA": "AAAA",
@@ -16,11 +16,19 @@ export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
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]);
+2 -1
View File
@@ -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"]),
+122 -106
View File
@@ -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"),
+5
View File
@@ -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;
@@ -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;
@@ -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", () => {