Skip to content

Commit 90745d1

Browse files
Ricardo AntunesRiscadoA
authored andcommitted
[wrangler] Add hostname validation for VPC services
1 parent bbe09b6 commit 90745d1

File tree

2 files changed

+207
-0
lines changed

2 files changed

+207
-0
lines changed

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)