Skip to content

Commit bff4947

Browse files
author
Ricardo Antunes
committed
[wrangler] Add hostname validation for VPC services
1 parent bbe09b6 commit bff4947

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ 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";
7+
import type { ServiceArgs } from "../vpc/validation";
68
import { endEventLoop } from "./helpers/end-event-loop";
79
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
810
import { mockConsoleMethods } from "./helpers/mock-console";
@@ -325,6 +327,150 @@ describe("vpc service commands", () => {
325327
});
326328
});
327329

330+
describe("hostname validation", () => {
331+
it("should accept valid hostnames", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
367+
expect(() => validateHostname("example.com/path")).toThrow(
368+
"Hostname must not include a path"
369+
);
370+
});
371+
372+
it("should reject bare IPv4 address", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
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", ({ expect }) => {
400+
expect(() => validateHostname("bad host.com")).toThrow(
401+
"Hostname must not contain whitespace"
402+
);
403+
});
404+
405+
it("should accept hostnames with underscores", ({ expect }) => {
406+
expect(() => validateHostname("_dmarc.example.com")).not.toThrow();
407+
expect(() => validateHostname("my_service.internal")).not.toThrow();
408+
});
409+
410+
it("should reject invalid hostname via wrangler service create", async () => {
411+
await expect(() =>
412+
runWrangler(
413+
"vpc service create test-bad-hostname --type http --hostname https://example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
414+
)
415+
).rejects.toThrow("Hostname must not include a URL scheme");
416+
});
417+
418+
it("should reject IP address as hostname via wrangler service create", async () => {
419+
await expect(() =>
420+
runWrangler(
421+
"vpc service create test-ip-hostname --type http --hostname 192.168.1.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
422+
)
423+
).rejects.toThrow("Hostname must not be an IP address");
424+
});
425+
});
426+
427+
describe("IP address validation", () => {
428+
const baseArgs: ServiceArgs = {
429+
name: "test",
430+
type: ServiceType.Http,
431+
tunnelId: "550e8400-e29b-41d4-a716-446655440000",
432+
};
433+
434+
it("should accept valid IPv4 addresses", ({ expect }) => {
435+
expect(() =>
436+
validateRequest({ ...baseArgs, ipv4: "192.168.1.1" })
437+
).not.toThrow();
438+
expect(() =>
439+
validateRequest({ ...baseArgs, ipv4: "10.0.0.1" })
440+
).not.toThrow();
441+
});
442+
443+
it("should reject invalid IPv4 addresses", ({ expect }) => {
444+
expect(() =>
445+
validateRequest({ ...baseArgs, ipv4: "not-an-ip" })
446+
).toThrow("Invalid IPv4 address");
447+
expect(() =>
448+
validateRequest({ ...baseArgs, ipv4: "999.999.999.999" })
449+
).toThrow("Invalid IPv4 address");
450+
expect(() =>
451+
validateRequest({ ...baseArgs, ipv4: "example.com" })
452+
).toThrow("Invalid IPv4 address");
453+
});
454+
455+
it("should accept valid IPv6 addresses", ({ expect }) => {
456+
expect(() =>
457+
validateRequest({ ...baseArgs, ipv6: "::1" })
458+
).not.toThrow();
459+
expect(() =>
460+
validateRequest({ ...baseArgs, ipv6: "2001:db8::1" })
461+
).not.toThrow();
462+
});
463+
464+
it("should reject invalid IPv6 addresses", ({ expect }) => {
465+
expect(() =>
466+
validateRequest({ ...baseArgs, ipv6: "not-an-ip" })
467+
).toThrow("Invalid IPv6 address");
468+
expect(() =>
469+
validateRequest({ ...baseArgs, ipv6: "192.168.1.1" })
470+
).toThrow("Invalid IPv6 address");
471+
});
472+
});
473+
328474
const mockService: ConnectivityService = {
329475
service_id: "service-uuid",
330476
type: ServiceType.Http,

packages/wrangler/src/vpc/validation.ts

Lines changed: 61 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,50 @@ 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+
if (trimmed.length > 253) {
26+
throw new UserError(
27+
"Hostname is too long. Maximum length is 253 characters."
28+
);
29+
}
30+
31+
if (trimmed.includes("://")) {
32+
throw new UserError(
33+
"Hostname must not include a URL scheme (e.g., remove 'https://')."
34+
);
35+
}
36+
37+
if (trimmed.includes("/")) {
38+
throw new UserError(
39+
"Hostname must not include a path. Provide only the hostname (e.g., 'api.example.com')."
40+
);
41+
}
42+
43+
// Check for bare IP addresses using Node.js built-in validation
44+
const bareValue = trimmed.replace(/^\[|\]$/g, "");
45+
if (net.isIPv4(trimmed) || net.isIPv6(bareValue)) {
46+
throw new UserError(
47+
"Hostname must not be an IP address. Use --ipv4 or --ipv6 instead."
48+
);
49+
}
50+
51+
if (trimmed.includes(":")) {
52+
throw new UserError(
53+
"Hostname must not include a port number. Provide only the hostname and use --http-port or --https-port for ports."
54+
);
55+
}
56+
57+
if (/\s/.test(trimmed)) {
58+
throw new UserError("Hostname must not contain whitespace.");
59+
}
60+
}
61+
1762
export function validateRequest(args: ServiceArgs) {
1863
// Validate host configuration - must have either IP addresses or hostname, not both
1964
const hasIpAddresses = Boolean(args.ipv4 || args.ipv6);
@@ -24,6 +69,22 @@ export function validateRequest(args: ServiceArgs) {
2469
"Must specify either IP addresses (--ipv4/--ipv6) or hostname (--hostname)"
2570
);
2671
}
72+
73+
if (args.ipv4 && !net.isIPv4(args.ipv4)) {
74+
throw new UserError(
75+
`Invalid IPv4 address: '${args.ipv4}'. Provide a valid IPv4 address (e.g., '192.168.1.1').`
76+
);
77+
}
78+
79+
if (args.ipv6 && !net.isIPv6(args.ipv6)) {
80+
throw new UserError(
81+
`Invalid IPv6 address: '${args.ipv6}'. Provide a valid IPv6 address (e.g., '2001:db8::1').`
82+
);
83+
}
84+
85+
if (hasHostname && args.hostname) {
86+
validateHostname(args.hostname);
87+
}
2788
}
2889

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

0 commit comments

Comments
 (0)