Fix RCE vulnerability in /api/ping endpoint

This addresses a follow-up to CVE-2021-46704 where the host validation
could be bypassed via domainToASCII stripping path components.

Changes:
- Normalize host with domainToASCII before validation
- Add round-trip check with domainToUnicode to catch path injection
- Switch from exec to execFile to prevent shell injection
- Add -- separator to ping arguments

Credit: JuHwiSang for reporting this vulnerability
This commit is contained in:
Zaid Humaimidi committed 2026-03-25 15:00:00 -07:00
1 parent cc723c0b50
commit 087b95388f
1 file changed
+16 -7
+16 -7
View File
@@ -1,6 +1,6 @@
import { platform } from "node:os"; import { platform } from "node:os";
import { exec } from "node:child_process"; import { execFile } from "node:child_process";
import { domainToASCII } from "node:url"; import { domainToASCII, domainToUnicode } from "node:url";
export interface PingResult { export interface PingResult {
packetsTransmitted: number; packetsTransmitted: number;
@@ -16,9 +16,16 @@ function isValidHost(host: string): boolean {
// Valid chars in IPv4, IPv6, domain names // Valid chars in IPv4, IPv6, domain names
if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true; if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true;
const lowercase = host.toLowerCase();
const ascii = domainToASCII(lowercase);
// Check if input is an IDN convert to Punycode // Check if input is an IDN convert to Punycode
// Can't merge with above because domainToASCII doesn't accept IP addresses // Can't merge with above because domainToASCII doesn't accept IP addresses
return /^[a-zA-Z0-9\-.:[\]-]+$/.test(domainToASCII(host)); if (!/^[a-zA-Z0-9\-.:[\]-]+$/.test(ascii)) return false;
// Round-trip to ensure the original host matches its normalized form.
// This prevents bypasses where domainToASCII strips paths (e.g. "example.com/;cmd").
return lowercase === domainToUnicode(ascii);
} }
export function parsePing(osPlatform: string, stdout: string): PingResult { export function parsePing(osPlatform: string, stdout: string): PingResult {
@@ -73,24 +80,26 @@ export function ping(
): void { ): void {
// Validate input to prevent possible remote code execution // Validate input to prevent possible remote code execution
// Credit to Alex Hordijk for reporting this vulnerability // Credit to Alex Hordijk for reporting this vulnerability
// Credit to JuHwiSang for reporting a follow-up vulnerability
if (!isValidHost(host)) return callback(new Error("Invalid host")); if (!isValidHost(host)) return callback(new Error("Invalid host"));
host = host.replace("[", "").replace("]", ""); host = host.replace("[", "").replace("]", "");
let cmd: string; let args: string[];
switch (platform()) { switch (platform()) {
case "linux": case "linux":
cmd = `ping -w 1 -i 0.2 -c 3 ${host}`; args = ["-w", "1", "-i", "0.2", "-c", "3", "--", host];
break; break;
case "freebsd": case "freebsd":
// Send a single packet because on FreeBSD only superuser can send // Send a single packet because on FreeBSD only superuser can send
// packets that are only 200 ms apart. // packets that are only 200 ms apart.
cmd = `ping -t 1 -c 3 ${host}`; args = ["-t", "1", "-c", "3", "--", host];
break; break;
default: default:
return callback(new Error("Platform not supported")); return callback(new Error("Platform not supported"));
} }
exec(cmd, (err, stdout) => { // Use execFile instead of exec to avoid shell interpretation of the host
execFile("ping", args, (err, stdout) => {
if (err) return callback(err); if (err) return callback(err);
const parsed: PingResult = parsePing(platform(), stdout); const parsed: PingResult = parsePing(platform(), stdout);
return callback(err, parsed, stdout); return callback(err, parsed, stdout);