Add validation for health check hostname

Fixes #3677
This commit is contained in:
Owen
2026-09-02 10:42:59 -04:00
parent 8d7e73afa8
commit e0937a3afa
4 changed files with 64 additions and 21 deletions
+1
View File
@@ -2717,6 +2717,7 @@
"healthScheme": "Method", "healthScheme": "Method",
"healthSelectScheme": "Select Method", "healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
"healthCheckPath": "Path", "healthCheckPath": "Path",
"healthHostname": "IP / Host", "healthHostname": "IP / Host",
"healthPort": "Port", "healthPort": "Port",
+12
View File
@@ -121,6 +121,13 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
// 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)) { if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets( await addProxyTargets(
site.newt.newtId, site.newt.newtId,
@@ -142,6 +149,11 @@ export async function applyBlueprint({
site.newt.version site.newt.version
); );
} }
} catch (e) {
logger.error(
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
);
}
} }
} }
} }
+27 -1
View File
@@ -29,8 +29,34 @@ export const SiteSchema = z.object({
"docker-socket-enabled": z.boolean().optional().default(true) "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({ export const TargetHealthCheckSchema = z.object({
hostname: z.string(), hostname: healthCheckHostnameSchema,
port: z.int().min(1).max(65535), port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
path: z.string().optional().default("/"), path: z.string().optional().default("/"),
+5 -1
View File
@@ -172,7 +172,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
.nullable() .nullable()
.optional(), .optional(),
hcScheme: z.string().optional(), hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z hcPort: z
.string() .string()
.min(1, { message: t("healthCheckPortInvalid") }) .min(1, { message: t("healthCheckPortInvalid") })
@@ -184,6 +183,11 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
{ message: t("healthCheckPortInvalid") } { message: t("healthCheckPortInvalid") }
), ),
hcFollowRedirects: z.boolean(), hcFollowRedirects: z.boolean(),
hcHostname: z
.string()
.refine((val) => !/\s/.test(val), {
message: t("healthCheckHostnameInvalid")
}),
hcMode: z.string(), hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5), hcUnhealthyInterval: z.int().positive().min(5),
hcTlsServerName: z.string(), hcTlsServerName: z.string(),