From e0937a3afa01e4f1da748101f75faae21e9c9562 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 2 Sep 2026 10:42:59 -0400 Subject: [PATCH] Add validation for health check hostname Fixes #3677 --- messages/en-US.json | 1 + server/lib/blueprints/applyBlueprint.ts | 50 +++++++++++++++---------- server/lib/blueprints/types.ts | 28 +++++++++++++- src/components/HealthCheckCredenza.tsx | 6 ++- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5199aee71..7ed4906c1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2717,6 +2717,7 @@ "healthScheme": "Method", "healthSelectScheme": "Select Method", "healthCheckPortInvalid": "Port must be between 1 and 65535", + "healthCheckHostnameInvalid": "Hostname must not contain whitespace", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 077b2112a..092118d04 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -121,25 +121,37 @@ export async function applyBlueprint({ (hc) => hc.targetId === target.targetId ); - if (["http", "tcp", "udp"].includes(target.mode)) { - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck - ? [matchingHealthcheck] - : [], - result.proxyResource.mode === "udp" - ? "udp" - : "tcp", - site.newt.version - ); - } else if ( - ["ssh", "rdp", "vnc"].includes(target.mode) - ) { - await sendBrowserGatewayTargets( - site.newt.newtId, - [target], - site.newt.version + // The DB writes for all resources have already committed + // by this point, so a push failure for one target (e.g. + // a newt rejecting a malformed health check) must not + // abort pushing the rest, and must not mark the whole + // blueprint as failed when the config was actually + // persisted successfully. + try { + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); + } + } catch (e) { + logger.error( + `Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}` ); } } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 4aaa0d2a8..238ef49c4 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -29,8 +29,34 @@ export const SiteSchema = z.object({ "docker-socket-enabled": z.boolean().optional().default(true) }); +// A malformed hostname (e.g. stray whitespace) is silently accepted here but +// fails to parse as a URL when newt builds the health check request, which +// takes the target out of the routing pool and breaks the resource entirely +// (see #3677). Validate eagerly so blueprints reject it up front instead. +const healthCheckHostnameSchema = z + .string() + .trim() + .min(1) + .refine((val) => !/\s/.test(val), { + message: "Hostname must not contain whitespace" + }) + .refine( + (val) => { + if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) { + return true; + } + const hostnameRegex = + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + return hostnameRegex.test(val); + }, + { + message: + "Hostname must be a valid IP address or hostname (no spaces or invalid characters)" + } + ); + export const TargetHealthCheckSchema = z.object({ - hostname: z.string(), + hostname: healthCheckHostnameSchema, port: z.int().min(1).max(65535), enabled: z.boolean().optional().default(true), path: z.string().optional().default("/"), diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index a01141199..43e39b9ae 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -172,7 +172,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { .nullable() .optional(), hcScheme: z.string().optional(), - hcHostname: z.string(), hcPort: z .string() .min(1, { message: t("healthCheckPortInvalid") }) @@ -184,6 +183,11 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { { message: t("healthCheckPortInvalid") } ), hcFollowRedirects: z.boolean(), + hcHostname: z + .string() + .refine((val) => !/\s/.test(val), { + message: t("healthCheckHostnameInvalid") + }), hcMode: z.string(), hcUnhealthyInterval: z.int().positive().min(5), hcTlsServerName: z.string(),