Skip to content

Commit 84f865f

Browse files
Ricardo AntunesRiscadoA
authored andcommitted
[wrangler] Add hostname validation for VPC services
1 parent 23a365a commit 84f865f

File tree

3 files changed

+232
-0
lines changed

3 files changed

+232
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Add client-side validation for VPC service host flags
6+
7+
The `--hostname`, `--ipv4`, and `--ipv6` flags on `wrangler vpc service create` and `wrangler vpc service update` now validate input before sending requests to the API. Previously, invalid values were accepted by the CLI and only rejected by the API with opaque error messages. Now users get clear, actionable error messages for common mistakes like passing a URL instead of a hostname, using an IP address in the `--hostname` flag, or providing malformed IP addresses.

packages/wrangler/src/__tests__/vpc.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
/* eslint-enable workers-sdk/no-vitest-import-expect */
55
import { ServiceType } from "../vpc/index";
6+
import { validateHostname, validateRequest } from "../vpc/validation";
67
import { endEventLoop } from "./helpers/end-event-loop";
78
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
89
import { mockConsoleMethods } from "./helpers/mock-console";
@@ -15,6 +16,7 @@ import type {
1516
ConnectivityService,
1617
ConnectivityServiceRequest,
1718
} from "../vpc/index";
19+
import type { ServiceArgs } from "../vpc/validation";
1820

1921
describe("vpc help", () => {
2022
const std = mockConsoleMethods();
@@ -325,6 +327,155 @@ describe("vpc service commands", () => {
325327
});
326328
});
327329

330+
describe("hostname validation", () => {
331+
it("should accept valid hostnames", () => {
332+
expect(() => validateHostname("api.example.com")).not.toThrow();
333+
expect(() => validateHostname("localhost")).not.toThrow();
334+
expect(() => validateHostname("my-service.internal.local")).not.toThrow();
335+
expect(() => validateHostname("sub.domain.example.co.uk")).not.toThrow();
336+
});
337+
338+
it("should reject empty hostname", () => {
339+
expect(() => validateHostname("")).toThrow("Hostname cannot be empty.");
340+
expect(() => validateHostname(" ")).toThrow("Hostname cannot be empty.");
341+
});
342+
343+
it("should reject hostname exceeding 253 characters", () => {
344+
const longHostname = "a".repeat(254);
345+
expect(() => validateHostname(longHostname)).toThrow(
346+
"Hostname is too long. Maximum length is 253 characters."
347+
);
348+
});
349+
350+
it("should accept hostname at exactly 253 characters", () => {
351+
const label = "a".repeat(63);
352+
const hostname = `${label}.${label}.${label}.${label.slice(0, 61)}`;
353+
expect(hostname.length).toBe(253);
354+
expect(() => validateHostname(hostname)).not.toThrow();
355+
});
356+
357+
it("should reject hostname with URL scheme", () => {
358+
expect(() => validateHostname("https://example.com")).toThrow(
359+
"Hostname must not include a URL scheme"
360+
);
361+
expect(() => validateHostname("http://example.com")).toThrow(
362+
"Hostname must not include a URL scheme"
363+
);
364+
});
365+
366+
it("should reject hostname with path", () => {
367+
expect(() => validateHostname("example.com/path")).toThrow(
368+
"Hostname must not include a path"
369+
);
370+
});
371+
372+
it("should reject bare IPv4 address", () => {
373+
expect(() => validateHostname("192.168.1.1")).toThrow(
374+
"Hostname must not be an IP address. Use --ipv4 or --ipv6 instead."
375+
);
376+
expect(() => validateHostname("10.0.0.1")).toThrow(
377+
"Hostname must not be an IP address"
378+
);
379+
});
380+
381+
it("should reject bare IPv6 address", () => {
382+
expect(() => validateHostname("::1")).toThrow(
383+
"Hostname must not be an IP address"
384+
);
385+
expect(() => validateHostname("2001:db8::1")).toThrow(
386+
"Hostname must not be an IP address"
387+
);
388+
expect(() => validateHostname("[::1]")).toThrow(
389+
"Hostname must not be an IP address"
390+
);
391+
});
392+
393+
it("should reject hostname with port", () => {
394+
expect(() => validateHostname("example.com:8080")).toThrow(
395+
"Hostname must not include a port number"
396+
);
397+
});
398+
399+
it("should reject hostname with whitespace", () => {
400+
expect(() => validateHostname("bad host.com")).toThrow(
401+
"Hostname must not contain whitespace"
402+
);
403+
});
404+
405+
it("should accept hostnames with underscores", () => {
406+
expect(() => validateHostname("_dmarc.example.com")).not.toThrow();
407+
expect(() => validateHostname("my_service.internal")).not.toThrow();
408+
});
409+
410+
it("should report all applicable errors at once", () => {
411+
// "https://example.com/path" has a scheme AND a path
412+
expect(() => validateHostname("https://example.com/path")).toThrow(
413+
/URL scheme.*\n.*path/s
414+
);
415+
});
416+
417+
it("should reject invalid hostname via wrangler service create", async () => {
418+
await expect(() =>
419+
runWrangler(
420+
"vpc service create test-bad-hostname --type http --hostname https://example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
421+
)
422+
).rejects.toThrow("Hostname must not include a URL scheme");
423+
});
424+
425+
it("should reject IP address as hostname via wrangler service create", async () => {
426+
await expect(() =>
427+
runWrangler(
428+
"vpc service create test-ip-hostname --type http --hostname 192.168.1.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
429+
)
430+
).rejects.toThrow("Hostname must not be an IP address");
431+
});
432+
});
433+
434+
describe("IP address validation", () => {
435+
const baseArgs: ServiceArgs = {
436+
name: "test",
437+
type: ServiceType.Http,
438+
tunnelId: "550e8400-e29b-41d4-a716-446655440000",
439+
};
440+
441+
it("should accept valid IPv4 addresses", () => {
442+
expect(() =>
443+
validateRequest({ ...baseArgs, ipv4: "192.168.1.1" })
444+
).not.toThrow();
445+
expect(() =>
446+
validateRequest({ ...baseArgs, ipv4: "10.0.0.1" })
447+
).not.toThrow();
448+
});
449+
450+
it("should reject invalid IPv4 addresses", () => {
451+
expect(() => validateRequest({ ...baseArgs, ipv4: "not-an-ip" })).toThrow(
452+
"Invalid IPv4 address"
453+
);
454+
expect(() =>
455+
validateRequest({ ...baseArgs, ipv4: "999.999.999.999" })
456+
).toThrow("Invalid IPv4 address");
457+
expect(() => validateRequest({ ...baseArgs, ipv4: "example.com" })).toThrow(
458+
"Invalid IPv4 address"
459+
);
460+
});
461+
462+
it("should accept valid IPv6 addresses", () => {
463+
expect(() => validateRequest({ ...baseArgs, ipv6: "::1" })).not.toThrow();
464+
expect(() =>
465+
validateRequest({ ...baseArgs, ipv6: "2001:db8::1" })
466+
).not.toThrow();
467+
});
468+
469+
it("should reject invalid IPv6 addresses", () => {
470+
expect(() => validateRequest({ ...baseArgs, ipv6: "not-an-ip" })).toThrow(
471+
"Invalid IPv6 address"
472+
);
473+
expect(() => validateRequest({ ...baseArgs, ipv6: "192.168.1.1" })).toThrow(
474+
"Invalid IPv6 address"
475+
);
476+
});
477+
});
478+
328479
const mockService: ConnectivityService = {
329480
service_id: "service-uuid",
330481
type: ServiceType.Http,

packages/wrangler/src/vpc/validation.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import net from "node:net";
12
import { UserError } from "@cloudflare/workers-utils";
23
import { ServiceType } from "./index";
34
import type { ConnectivityServiceRequest, ServiceHost } from "./index";
@@ -14,6 +15,63 @@ export interface ServiceArgs {
1415
resolverIps?: string;
1516
}
1617

18+
export function validateHostname(hostname: string): void {
19+
const trimmed = hostname.trim();
20+
21+
if (trimmed.length === 0) {
22+
throw new UserError("Hostname cannot be empty.");
23+
}
24+
25+
const errors: string[] = [];
26+
27+
if (trimmed.length > 253) {
28+
errors.push("Hostname is too long. Maximum length is 253 characters.");
29+
}
30+
31+
const hasScheme = trimmed.includes("://");
32+
if (hasScheme) {
33+
errors.push(
34+
"Hostname must not include a URL scheme (e.g., remove 'https://')."
35+
);
36+
}
37+
38+
const afterScheme = hasScheme
39+
? trimmed.slice(trimmed.indexOf("://") + 3)
40+
: trimmed;
41+
if (afterScheme.includes("/")) {
42+
errors.push(
43+
"Hostname must not include a path. Provide only the hostname (e.g., 'api.example.com')."
44+
);
45+
}
46+
47+
// Check for bare IP addresses using Node.js built-in validation
48+
const bareValue = trimmed.replace(/^\[|\]$/g, "");
49+
const isIpAddress = net.isIPv4(trimmed) || net.isIPv6(bareValue);
50+
if (isIpAddress) {
51+
errors.push(
52+
"Hostname must not be an IP address. Use --ipv4 or --ipv6 instead."
53+
);
54+
}
55+
56+
// Only check for port numbers when the colon isn't already explained by
57+
// an IPv6 address or a URL scheme, to avoid misleading error messages.
58+
if (!isIpAddress && !hasScheme && trimmed.includes(":")) {
59+
errors.push(
60+
"Hostname must not include a port number. Provide only the hostname and use --http-port or --https-port for ports."
61+
);
62+
}
63+
64+
if (/\s/.test(trimmed)) {
65+
errors.push("Hostname must not contain whitespace.");
66+
}
67+
68+
if (errors.length > 0) {
69+
throw new UserError(
70+
`Invalid hostname '${trimmed}':\n${errors.map((e) => ` - ${e}`).join("\n")}`
71+
);
72+
}
73+
}
74+
1775
export function validateRequest(args: ServiceArgs) {
1876
// Validate host configuration - must have either IP addresses or hostname, not both
1977
const hasIpAddresses = Boolean(args.ipv4 || args.ipv6);
@@ -24,6 +82,22 @@ export function validateRequest(args: ServiceArgs) {
2482
"Must specify either IP addresses (--ipv4/--ipv6) or hostname (--hostname)"
2583
);
2684
}
85+
86+
if (args.ipv4 && !net.isIPv4(args.ipv4)) {
87+
throw new UserError(
88+
`Invalid IPv4 address: '${args.ipv4}'. Provide a valid IPv4 address (e.g., '192.168.1.1').`
89+
);
90+
}
91+
92+
if (args.ipv6 && !net.isIPv6(args.ipv6)) {
93+
throw new UserError(
94+
`Invalid IPv6 address: '${args.ipv6}'. Provide a valid IPv6 address (e.g., '2001:db8::1').`
95+
);
96+
}
97+
98+
if (hasHostname && args.hostname) {
99+
validateHostname(args.hostname);
100+
}
27101
}
28102

29103
export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest {

0 commit comments

Comments
 (0)