Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vpc-hostname-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Add client-side validation for VPC service host flags

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.
185 changes: 185 additions & 0 deletions packages/wrangler/src/__tests__/vpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/* eslint-enable workers-sdk/no-vitest-import-expect */
import { ServiceType } from "../vpc/index";
import { validateHostname, validateRequest } from "../vpc/validation";
import { endEventLoop } from "./helpers/end-event-loop";
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
import { mockConsoleMethods } from "./helpers/mock-console";
Expand All @@ -15,6 +16,7 @@ import type {
ConnectivityService,
ConnectivityServiceRequest,
} from "../vpc/index";
import type { ServiceArgs } from "../vpc/validation";

describe("vpc help", () => {
const std = mockConsoleMethods();
Expand Down Expand Up @@ -325,6 +327,189 @@ describe("vpc service commands", () => {
});
});

describe("hostname validation", () => {
it("should accept valid hostnames", () => {
expect(() => validateHostname("api.example.com")).not.toThrow();
expect(() => validateHostname("localhost")).not.toThrow();
expect(() => validateHostname("my-service.internal.local")).not.toThrow();
expect(() => validateHostname("sub.domain.example.co.uk")).not.toThrow();
});

it("should reject empty hostname", () => {
expect(() => validateHostname("")).toThrow("Hostname cannot be empty.");
expect(() => validateHostname(" ")).toThrow("Hostname cannot be empty.");
});

it("should reject hostname exceeding 253 characters", () => {
const longHostname = "a".repeat(254);
expect(() => validateHostname(longHostname)).toThrow(
"Hostname is too long. Maximum length is 253 characters."
);
});

it("should accept hostname at exactly 253 characters", () => {
const label = "a".repeat(63);
const hostname = `${label}.${label}.${label}.${label.slice(0, 61)}`;
expect(hostname.length).toBe(253);
expect(() => validateHostname(hostname)).not.toThrow();
});

it("should reject hostname with URL scheme", () => {
expect(() => validateHostname("https://example.com")).toThrow(
"Hostname must not include a URL scheme"
);
expect(() => validateHostname("http://example.com")).toThrow(
"Hostname must not include a URL scheme"
);
});

it("should reject hostname with path", () => {
expect(() => validateHostname("example.com/path")).toThrow(
"Hostname must not include a path"
);
});

it("should reject bare IPv4 address", () => {
expect(() => validateHostname("192.168.1.1")).toThrow(
"Hostname must not be an IP address. Use --ipv4 or --ipv6 instead."
);
expect(() => validateHostname("10.0.0.1")).toThrow(
"Hostname must not be an IP address"
);
});

it("should reject bare IPv6 address", () => {
expect(() => validateHostname("::1")).toThrow(
"Hostname must not be an IP address"
);
expect(() => validateHostname("2001:db8::1")).toThrow(
"Hostname must not be an IP address"
);
expect(() => validateHostname("[::1]")).toThrow(
"Hostname must not be an IP address"
);
});

it("should reject hostname with port", () => {
expect(() => validateHostname("example.com:8080")).toThrow(
"Hostname must not include a port number"
);
});

it("should reject hostname with whitespace", () => {
expect(() => validateHostname("bad host.com")).toThrow(
"Hostname must not contain whitespace"
);
});

it("should accept hostnames with underscores", () => {
expect(() => validateHostname("_dmarc.example.com")).not.toThrow();
expect(() => validateHostname("my_service.internal")).not.toThrow();
});

it("should report all applicable errors at once", () => {
// "https://example.com/path" has a scheme AND a path
expect(() => validateHostname("https://example.com/path")).toThrow(
/URL scheme.*\n.*path/s
);
});

it("should reject invalid hostname via wrangler service create", async () => {
await expect(() =>
runWrangler(
"vpc service create test-bad-hostname --type http --hostname https://example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
)
).rejects.toThrow("Hostname must not include a URL scheme");
});

it("should reject IP address as hostname via wrangler service create", async () => {
await expect(() =>
runWrangler(
"vpc service create test-ip-hostname --type http --hostname 192.168.1.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000"
)
).rejects.toThrow("Hostname must not be an IP address");
});
});

describe("IP address validation", () => {
const baseArgs: ServiceArgs = {
name: "test",
type: ServiceType.Http,
tunnelId: "550e8400-e29b-41d4-a716-446655440000",
};

it("should accept valid IPv4 addresses", () => {
expect(() =>
validateRequest({ ...baseArgs, ipv4: "192.168.1.1" })
).not.toThrow();
expect(() =>
validateRequest({ ...baseArgs, ipv4: "10.0.0.1" })
).not.toThrow();
});

it("should reject invalid IPv4 addresses", () => {
expect(() => validateRequest({ ...baseArgs, ipv4: "not-an-ip" })).toThrow(
"Invalid IPv4 address"
);
expect(() =>
validateRequest({ ...baseArgs, ipv4: "999.999.999.999" })
).toThrow("Invalid IPv4 address");
expect(() => validateRequest({ ...baseArgs, ipv4: "example.com" })).toThrow(
"Invalid IPv4 address"
);
});

it("should accept valid IPv6 addresses", () => {
expect(() => validateRequest({ ...baseArgs, ipv6: "::1" })).not.toThrow();
expect(() =>
validateRequest({ ...baseArgs, ipv6: "2001:db8::1" })
).not.toThrow();
});

it("should reject invalid IPv6 addresses", () => {
expect(() => validateRequest({ ...baseArgs, ipv6: "not-an-ip" })).toThrow(
"Invalid IPv6 address"
);
expect(() => validateRequest({ ...baseArgs, ipv6: "192.168.1.1" })).toThrow(
"Invalid IPv6 address"
);
});

it("should accept valid resolver IPs", () => {
expect(() =>
validateRequest({
...baseArgs,
hostname: "example.com",
resolverIps: "8.8.8.8,8.8.4.4",
})
).not.toThrow();
expect(() =>
validateRequest({
...baseArgs,
hostname: "example.com",
resolverIps: "2001:db8::1",
})
).not.toThrow();
});

it("should reject invalid resolver IPs", () => {
expect(() =>
validateRequest({
...baseArgs,
hostname: "example.com",
resolverIps: "not-an-ip",
})
).toThrow("Invalid resolver IP address(es): 'not-an-ip'");
expect(() =>
validateRequest({
...baseArgs,
hostname: "example.com",
resolverIps: "8.8.8.8,bad-ip,1.1.1.1",
})
).toThrow("Invalid resolver IP address(es): 'bad-ip'");
});
});

const mockService: ConnectivityService = {
service_id: "service-uuid",
type: ServiceType.Http,
Expand Down
91 changes: 90 additions & 1 deletion packages/wrangler/src/vpc/validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import net from "node:net";
import { UserError } from "@cloudflare/workers-utils";
import { ServiceType } from "./index";
import type { ConnectivityServiceRequest, ServiceHost } from "./index";
Expand All @@ -14,6 +15,63 @@ export interface ServiceArgs {
resolverIps?: string;
}

export function validateHostname(hostname: string): void {
Comment thread
RiscadoA marked this conversation as resolved.
const trimmed = hostname.trim();

if (trimmed.length === 0) {
throw new UserError("Hostname cannot be empty.");
}

const errors: string[] = [];

if (trimmed.length > 253) {
errors.push("Hostname is too long. Maximum length is 253 characters.");
}

const hasScheme = trimmed.includes("://");
if (hasScheme) {
errors.push(
"Hostname must not include a URL scheme (e.g., remove 'https://')."
);
}

const afterScheme = hasScheme
? trimmed.slice(trimmed.indexOf("://") + 3)
: trimmed;
if (afterScheme.includes("/")) {
errors.push(
"Hostname must not include a path. Provide only the hostname (e.g., 'api.example.com')."
);
}

// Check for bare IP addresses using Node.js built-in validation
const bareValue = trimmed.replace(/^\[|\]$/g, "");
const isIpAddress = net.isIPv4(trimmed) || net.isIPv6(bareValue);
if (isIpAddress) {
errors.push(
"Hostname must not be an IP address. Use --ipv4 or --ipv6 instead."
);
}

// Only check for port numbers when the colon isn't already explained by
// an IPv6 address or a URL scheme, to avoid misleading error messages.
if (!isIpAddress && !hasScheme && trimmed.includes(":")) {
errors.push(
"Hostname must not include a port number. Provide only the hostname and use --http-port or --https-port for ports."
);
}

if (/\s/.test(trimmed)) {
errors.push("Hostname must not contain whitespace.");
}
Comment thread
RiscadoA marked this conversation as resolved.

if (errors.length > 0) {
throw new UserError(
`Invalid hostname '${trimmed}':\n${errors.map((e) => ` - ${e}`).join("\n")}`
);
}
}

export function validateRequest(args: ServiceArgs) {
// Validate host configuration - must have either IP addresses or hostname, not both
const hasIpAddresses = Boolean(args.ipv4 || args.ipv6);
Expand All @@ -24,13 +82,44 @@ export function validateRequest(args: ServiceArgs) {
"Must specify either IP addresses (--ipv4/--ipv6) or hostname (--hostname)"
);
}

if (args.ipv4 && !net.isIPv4(args.ipv4)) {
throw new UserError(
`Invalid IPv4 address: '${args.ipv4}'. Provide a valid IPv4 address (e.g., '192.168.1.1').`
);
}

if (args.ipv6 && !net.isIPv6(args.ipv6)) {
throw new UserError(
`Invalid IPv6 address: '${args.ipv6}'. Provide a valid IPv6 address (e.g., '2001:db8::1').`
);
}

if (hasHostname && args.hostname) {
validateHostname(args.hostname);
}

if (args.resolverIps) {
const ips = args.resolverIps.split(",").map((ip) => ip.trim());
const invalidIps = ips.filter(
(ip) => ip.length > 0 && !net.isIPv4(ip) && !net.isIPv6(ip)
);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (invalidIps.length > 0) {
throw new UserError(
`Invalid resolver IP address(es): ${invalidIps.map((ip) => `'${ip}'`).join(", ")}. Provide valid IPv4 or IPv6 addresses.`
);
}
}
}

export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest {
Comment thread
RiscadoA marked this conversation as resolved.
// Parse resolver IPs if provided
let resolverIpsList: string[] | undefined = undefined;
if (args.resolverIps) {
resolverIpsList = args.resolverIps.split(",").map((ip) => ip.trim());
resolverIpsList = args.resolverIps
.split(",")
.map((ip) => ip.trim())
.filter((ip) => ip.length > 0);
}

// Build the host configuration
Expand Down
Loading