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
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./crypto";
export * from "./url-validation";
export * from "./target-validation";
export * from "./security";
226 changes: 226 additions & 0 deletions src/lib/target-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* Target validation utilities for Globalping measurements
* Ensures only public endpoints are tested (Globalping API requirement)
*/

/**
* Check if an IPv4 address is in a private range (RFC1918)
* Private ranges:
* - 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
* - 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
* - 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
*
* @param ip The IPv4 address to check
* @returns True if the IP is in a private range
*/
export function isPrivateIPv4(ip: string): boolean {
// Remove any IPv6 brackets if present
const cleanIp = ip.replace(/^\[|\]$/g, "");

// Parse IPv4 address into octets
const parts = cleanIp.split(".");
if (parts.length !== 4) {
return false;
}

const octets = parts.map((part) => Number.parseInt(part, 10));

// Validate all octets are numbers 0-255
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return false;
}

const [first, second] = octets;

// 10.0.0.0/8
if (first === 10) {
return true;
}

// 172.16.0.0/12
if (first === 172 && second >= 16 && second <= 31) {
return true;
}

// 192.168.0.0/16
if (first === 192 && second === 168) {
return true;
}

return false;
}

/**
* Check if an IPv6 address is in a private range
* Private ranges:
* - fc00::/7 (Unique Local Addresses)
* - fe80::/10 (Link-local addresses)
*
* @param ip The IPv6 address to check
* @returns True if the IP is in a private range
*/
export function isPrivateIPv6(ip: string): boolean {
// Remove brackets if present
const cleanIp = ip.replace(/^\[|\]$/g, "").toLowerCase();

// fc00::/7 - Unique Local Addresses (fc00:: to fdff::)
if (cleanIp.startsWith("fc") || cleanIp.startsWith("fd")) {
return true;
}

// fe80::/10 - Link-local addresses
if (cleanIp.startsWith("fe8") || cleanIp.startsWith("fe9") || cleanIp.startsWith("fea") || cleanIp.startsWith("feb")) {
return true;
}

return false;
}

/**
* Check if an IPv4 address is a loopback address
* Loopback range: 127.0.0.0/8
*
* @param ip The IPv4 address to check
* @returns True if the IP is a loopback address
*/
export function isLoopbackIPv4(ip: string): boolean {
const cleanIp = ip.replace(/^\[|\]$/g, "");
const parts = cleanIp.split(".");

if (parts.length !== 4) {
return false;
}

const first = Number.parseInt(parts[0], 10);
return first === 127;
}

/**
* Check if an IPv6 address is a loopback address
* Loopback: ::1
*
* @param ip The IPv6 address to check
* @returns True if the IP is a loopback address
*/
export function isLoopbackIPv6(ip: string): boolean {
const cleanIp = ip.replace(/^\[|\]$/g, "").toLowerCase();

// ::1 is the only IPv6 loopback
return cleanIp === "::1" || cleanIp === "0:0:0:0:0:0:0:1" || cleanIp === "0000:0000:0000:0000:0000:0000:0000:0001";
}

/**
* Check if an IPv4 address is a link-local address (APIPA)
* Link-local range: 169.254.0.0/16
*
* @param ip The IPv4 address to check
* @returns True if the IP is a link-local address
*/
export function isLinkLocalIPv4(ip: string): boolean {
const cleanIp = ip.replace(/^\[|\]$/g, "");
const parts = cleanIp.split(".");

if (parts.length !== 4) {
return false;
}

const octets = parts.map((part) => Number.parseInt(part, 10));

// Validate octets
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
return false;
}

// 169.254.0.0/16
return octets[0] === 169 && octets[1] === 254;
}

/**
* Check if a target is a localhost domain
* Matches: localhost, *.localhost
*
* @param target The target to check
* @returns True if the target is a localhost domain
*/
export function isLocalhostDomain(target: string): boolean {
const lower = target.toLowerCase();
return lower === "localhost" || lower.endsWith(".localhost");
}
Comment on lines +145 to +148
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Localhost FQDNs and IPv4‑mapped IPv6 addresses currently bypass the “public target” guard

Two notable edge cases slip through isPublicTarget right now:

  1. Localhost with a trailing root dot
    Domains such as "localhost." and "app.localhost." are standard FQDN representations of the localhost namespace, but isLocalhostDomain only matches "localhost" and *.localhost without a trailing dot. These inputs are therefore treated as public and accepted.

  2. IPv4‑mapped IPv6 literals with private IPv4 parts
    Addresses like ::ffff:10.0.0.1 or 0:0:0:0:0:ffff:192.168.1.1 are IPv6 forms of private IPv4 addresses. The current logic only checks IPv6 ULA/link‑local prefixes (fc, fd, fe8feb) and does not look for IPv4‑mapped forms, so these will be classified as public even though they conceptually target the same private space you’re trying to block.

If the underlying Globalping stack accepts IPv4‑mapped IPv6 addresses (common in many networking stacks), this creates a straightforward way to sidestep the private‑IP restriction.

You can tighten both cases with small changes:

-export function isLocalhostDomain(target: string): boolean {
-	const lower = target.toLowerCase();
-	return lower === "localhost" || lower.endsWith(".localhost");
-}
+export function isLocalhostDomain(target: string): boolean {
+	const lower = target.toLowerCase();
+
+	// Normalize a trailing root dot (e.g., "localhost.", "app.localhost.")
+	const normalized = lower.endsWith(".") ? lower.slice(0, -1) : lower;
+
+	return normalized === "localhost" || normalized.endsWith(".localhost");
+}

And in isPublicTarget, normalize IPv4‑mapped IPv6 into the IPv4 path so the existing private/loopback/link‑local checks apply:

 	// Try to determine if it's an IP address
 	// IPv6 addresses may be in brackets
 	const ipToCheck = cleanTarget.replace(/^\[|\]$/g, "");
 
-	// Check if it looks like an IPv4 address (contains only digits and dots)
-	const ipv4Pattern = /^[\d.]+$/;
-	if (ipv4Pattern.test(ipToCheck)) {
+	// Handle IPv4-mapped IPv6 like ::ffff:10.0.0.1 or 0:0:0:0:0:ffff:10.0.0.1
+	const ipv4MappedMatch = ipToCheck.match(
+		/^(?:\:\:ffff:|0:0:0:0:0:ffff:)(\d+\.\d+\.\d+\.\d+)$/i,
+	);
+	const ipv4Candidate = ipv4MappedMatch ? ipv4MappedMatch[1] : ipToCheck;
+
+	// Check if it looks like an IPv4 address (contains only digits and dots)
+	const ipv4Pattern = /^[\d.]+$/;
+	if (ipv4Pattern.test(ipv4Candidate)) {
 		// Check IPv4 loopback
-		if (isLoopbackIPv4(ipToCheck)) {
+		if (isLoopbackIPv4(ipv4Candidate)) {
 			return {
 				valid: false,
-				reason: `${ipToCheck} is a loopback address (127.0.0.0/8)`,
+				reason: `${ipv4Candidate} is a loopback address (127.0.0.0/8)`,
 			};
 		}
 
 		// Check IPv4 private ranges
-		if (isPrivateIPv4(ipToCheck)) {
+		if (isPrivateIPv4(ipv4Candidate)) {
 			return {
 				valid: false,
-				reason: `${ipToCheck} is a private IPv4 address (RFC1918)`,
+				reason: `${ipv4Candidate} is a private IPv4 address (RFC1918)`,
 			};
 		}
 
 		// Check IPv4 link-local
-		if (isLinkLocalIPv4(ipToCheck)) {
+		if (isLinkLocalIPv4(ipv4Candidate)) {
 			return {
 				valid: false,
-				reason: `${ipToCheck} is a link-local address (169.254.0.0/16)`,
+				reason: `${ipv4Candidate} is a link-local address (169.254.0.0/16)`,
 			};
 		}
 	}

Separately, if target can ever include a port (e.g., "10.0.0.1:80"), that form will currently bypass the IPv4 checks because of the colon and be treated as IPv6‑ish, eventually classified as public. Either enforce a “host only” invariant at all call sites or strip an IPv4‑style host:port before running this logic to avoid that loophole as well.

Also applies to: 157-225

🤖 Prompt for AI Agents
In src/lib/target-validation.ts around lines 145–148 (and also affecting
157–225), isLocalhostDomain and isPublicTarget miss FQDNs with a trailing dot
(e.g., "localhost." or "app.localhost.") and fail to detect IPv4‑mapped IPv6
literals (e.g., "::ffff:10.0.0.1"), and host:port forms can confuse IPv4 checks;
fix by normalizing input: trim trailing dot from domain names before matching
localhost and subdomains, strip a trailing ":port" when present (only for
IPv4-style host:port), detect and extract IPv4 from IPv4‑mapped IPv6 addresses
(map ::ffff:a.b.c.d to a.b.c.d) and then reuse the existing IPv4
private/loopback checks so these cases are properly classified as non-public.


/**
* Validate if a target is a public endpoint
* Globalping only supports public endpoints - no private IPs, localhost, or link-local addresses
*
* @param target The target domain or IP address to validate
* @returns Validation result with reason if invalid
*/
export function isPublicTarget(target: string): { valid: boolean; reason?: string } {
if (!target || target.trim() === "") {
return { valid: false, reason: "Target cannot be empty" };
}

const cleanTarget = target.trim();

// Check for localhost domain patterns
if (isLocalhostDomain(cleanTarget)) {
return {
valid: false,
reason: `'${cleanTarget}' is a localhost domain`,
};
}

// Try to determine if it's an IP address
// IPv6 addresses may be in brackets
const ipToCheck = cleanTarget.replace(/^\[|\]$/g, "");

// Check if it looks like an IPv4 address (contains only digits and dots)
const ipv4Pattern = /^[\d.]+$/;
if (ipv4Pattern.test(ipToCheck)) {
// Check IPv4 loopback
if (isLoopbackIPv4(ipToCheck)) {
return {
valid: false,
reason: `${ipToCheck} is a loopback address (127.0.0.0/8)`,
};
}

// Check IPv4 private ranges
if (isPrivateIPv4(ipToCheck)) {
return {
valid: false,
reason: `${ipToCheck} is a private IPv4 address (RFC1918)`,
};
}

// Check IPv4 link-local
if (isLinkLocalIPv4(ipToCheck)) {
return {
valid: false,
reason: `${ipToCheck} is a link-local address (169.254.0.0/16)`,
};
}
}

// Check if it looks like an IPv6 address (contains colons)
if (ipToCheck.includes(":")) {
// Check IPv6 loopback
if (isLoopbackIPv6(ipToCheck)) {
return {
valid: false,
reason: `${ipToCheck} is the IPv6 loopback address`,
};
}

// Check IPv6 private ranges
if (isPrivateIPv6(ipToCheck)) {
return {
valid: false,
reason: `${ipToCheck} is a private IPv6 address`,
};
}
}

// If it doesn't match any private/local patterns, assume it's public
// (could be a domain name or a public IP)
return { valid: true };
}
61 changes: 51 additions & 10 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runMeasurement, getLocations, getRateLimits } from "../api";
import { parseLocations, formatMeasurementSummary } from "./helpers";
import type { GlobalpingMCP } from "../index";
import { maskToken } from "../auth";
import { isPublicTarget } from "../lib";

/**
* Helper to wrap tool execution with error handling
Expand Down Expand Up @@ -38,14 +39,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
{
title: "Ping Test",
description:
"Measure network latency, packet loss, and reachability to a target (domain or IP) from globally distributed probes. Use this tool to check if a server is online, debug connection issues, or assess global performance.",
"Measure network latency, packet loss, and reachability to a target (domain or IP) from globally distributed probes. Use this tool to check if a server is online, debug connection issues, or assess global performance. Note: Only public endpoints are supported. Private networks cannot be tested.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
target: z
.string()
.describe("Domain name or IP to test (e.g., 'google.com', '1.1.1.1')"),
.describe("Public domain name or IP address to test (e.g., 'google.com', '1.1.1.1'). Private IPs (RFC1918), localhost, and link-local addresses are not supported."),
locations: z
.union([z.array(z.string()), z.string()])
.optional()
Expand All @@ -71,6 +72,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
},
async ({ target, locations, limit, packets }) => {
return handleToolExecution(async () => {
// Validate target is public
const validation = isPublicTarget(target);
if (!validation.valid) {
throw new Error(
`Invalid target: ${validation.reason}. Globalping only supports public endpoints. Private IP addresses (RFC1918), localhost, and link-local addresses are not allowed.`,
);
}

const token = getToken();
const parsedLocations = parseLocations(locations);

Expand Down Expand Up @@ -126,14 +135,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
{
title: "Traceroute Test",
description:
"Trace the network path to a target (domain or IP) from global locations. Use this tool to identify where packets are being dropped, analyze routing paths, or pinpoint latency sources in the network.",
"Trace the network path to a target (domain or IP) from global locations. Use this tool to identify where packets are being dropped, analyze routing paths, or pinpoint latency sources in the network. Note: Only public endpoints are supported. Private networks cannot be tested.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
target: z
.string()
.describe("Domain name or IP to test (e.g., 'cloudflare.com', '1.1.1.1')"),
.describe("Public domain name or IP address to test (e.g., 'cloudflare.com', '1.1.1.1'). Private IPs (RFC1918), localhost, and link-local addresses are not supported."),
locations: z
.union([z.array(z.string()), z.string()])
.optional()
Expand Down Expand Up @@ -166,6 +175,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
},
async ({ target, locations, limit, protocol, port }) => {
return handleToolExecution(async () => {
// Validate target is public
const validation = isPublicTarget(target);
if (!validation.valid) {
throw new Error(
`Invalid target: ${validation.reason}. Globalping only supports public endpoints. Private IP addresses (RFC1918), localhost, and link-local addresses are not allowed.`,
);
}

const token = getToken();
const parsedLocations = parseLocations(locations);

Expand Down Expand Up @@ -221,12 +238,12 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
{
title: "DNS Lookup",
description:
"Resolve DNS records (A, AAAA, MX, etc.) for a domain from global locations. Use this tool to verify DNS propagation, troubleshoot resolution failures, or check if users in different regions are seeing the correct records.",
"Resolve DNS records (A, AAAA, MX, etc.) for a domain from global locations. Use this tool to verify DNS propagation, troubleshoot resolution failures, or check if users in different regions are seeing the correct records. Note: Only public endpoints are supported. Private networks cannot be tested.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
target: z.string().describe("Domain name to resolve (e.g., 'google.com')"),
target: z.string().describe("Public domain name to resolve (e.g., 'google.com'). Private domains, localhost, and link-local addresses are not supported."),
locations: z
.union([z.array(z.string()), z.string()])
.optional()
Expand Down Expand Up @@ -282,6 +299,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
},
async ({ target, locations, limit, queryType, resolver, trace }) => {
return handleToolExecution(async () => {
// Validate target is public
const validation = isPublicTarget(target);
if (!validation.valid) {
throw new Error(
`Invalid target: ${validation.reason}. Globalping only supports public endpoints. Private IP addresses (RFC1918), localhost, and link-local addresses are not allowed.`,
);
}

const token = getToken();
const parsedLocations = parseLocations(locations);

Expand Down Expand Up @@ -340,15 +365,15 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
{
title: "MTR Test",
description:
"Run an MTR (My Traceroute) diagnostic, which combines Ping and Traceroute. Use this tool to analyze packet loss and latency trends at every hop in the network path over time, helpful for spotting intermittent issues.",
"Run an MTR (My Traceroute) diagnostic, which combines Ping and Traceroute. Use this tool to analyze packet loss and latency trends at every hop in the network path over time, helpful for spotting intermittent issues. Note: Only public endpoints are supported. Private networks cannot be tested.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
target: z
.string()
.min(1)
.describe("Destination hostname or IP to run the MTR against"),
.describe("Public destination hostname or IP address to run the MTR against. Private IPs (RFC1918), localhost, and link-local addresses are not supported."),
locations: z
.union([z.array(z.string()), z.string()])
.optional()
Expand Down Expand Up @@ -384,6 +409,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
},
async ({ target, locations, limit, protocol, port, packets }) => {
return handleToolExecution(async () => {
// Validate target is public
const validation = isPublicTarget(target);
if (!validation.valid) {
throw new Error(
`Invalid target: ${validation.reason}. Globalping only supports public endpoints. Private IP addresses (RFC1918), localhost, and link-local addresses are not allowed.`,
);
}

const token = getToken();
const parsedLocations = parseLocations(locations);

Expand Down Expand Up @@ -440,12 +473,12 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
{
title: "HTTP Request",
description:
"Send HTTP/HTTPS requests (GET, HEAD or OPTIONS) to a URL from global locations. Use this tool to check website uptime, verify response status codes, analyze timing (TTFB, download), and debug CDN or caching issues.",
"Send HTTP/HTTPS requests (GET, HEAD or OPTIONS) to a URL from global locations. Use this tool to check website uptime, verify response status codes, analyze timing (TTFB, download), and debug CDN or caching issues. Note: Only public endpoints are supported. Private networks cannot be tested.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
target: z.string().describe("Domain name or IP to test (e.g., 'example.com')"),
target: z.string().describe("Public domain name or IP address to test (e.g., 'example.com'). Private IPs (RFC1918), localhost, and link-local addresses are not supported."),
locations: z
.union([z.array(z.string()), z.string()])
.optional()
Expand Down Expand Up @@ -492,6 +525,14 @@ export function registerGlobalpingTools(agent: GlobalpingMCP, getToken: () => st
},
async ({ target, locations, limit, method, protocol, path, query, port }) => {
return handleToolExecution(async () => {
// Validate target is public
const validation = isPublicTarget(target);
if (!validation.valid) {
throw new Error(
`Invalid target: ${validation.reason}. Globalping only supports public endpoints. Private IP addresses (RFC1918), localhost, and link-local addresses are not allowed.`,
);
}

const token = getToken();
const parsedLocations = parseLocations(locations);

Expand Down
Loading