re.match Prefix-Only Trap: The Hostname Validator That Waves Through Shell Injection2026-08-25
This function is supposed to accept only safe hostnames — lowercase alphanumerics, dots, hyphens — before shelling out to ping. It rejects obvious garbage. It also cheerfully waves through rm -rf /.
import re
import subprocess
HOSTNAME_RE = r"[a-z0-9.-]+"
def ping(host: str) -> bool:
if not re.match(HOSTNAME_RE, host):
raise ValueError(f"invalid hostname: {host!r}")
result = subprocess.run(
f"ping -c 1 {host}",
shell=True,
capture_output=True,
timeout=5,
)
return result.returncode == 0
# Rejected as expected:
ping("!!!") # ValueError
# Accepted, as expected:
ping("example.com") # True
# Accepted — and executes the trailing command:
ping("example.com; touch /tmp/pwned") # True
re.match only anchors at the start of the string. It does not require the pattern to consume the entire string. Once [a-z0-9.-]+ greedily matches the leading example.com, the match object is truthy and validation passes. The trailing ; touch /tmp/pwned is ignored by the regex — but not by the shell that runs a few lines later.
This is easy to miss because the rejected case ("!!!") also does the intuitive thing: no allowlisted character appears at the start, so re.match returns None. The validator looks like it works. Every unit test with a well-formed hostname passes. Every unit test with an obviously-malformed hostname fails. Nothing tests a well-formed hostname followed by garbage — which is exactly the shape an attacker will send.
Newcomers assume re.match is "match the whole string" (it's not — that's re.fullmatch, added in 3.4) or that + is inherently anchored at both ends (it's not — quantifiers only bind where the engine is currently reading). The name doesn't help: in most other languages, "match" means the entire input.
Two correct fixes:
# Option 1: fullmatch requires the pattern to consume the whole string.
if not re.fullmatch(HOSTNAME_RE, host):
raise ValueError(...)
# Option 2: anchor explicitly. \A and \Z beat ^/$ because
# ^/$ can match around embedded newlines in multiline mode.
if not re.match(r"\A[a-z0-9.-]+\Z", host):
raise ValueError(...)
A subtler note on option 2: don't reach for ^...$. In multiline mode $ matches before a \n, so "example.com\n; rm -rf /" can still slip past. \A and \Z are true string anchors and don't care about mode flags.
Better still: don't build shell commands by string interpolation at all. Passing a list bypasses the shell entirely, so even if your validator has a hole, there's nothing to inject into:
subprocess.run(["ping", "-c", "1", host], capture_output=True, timeout=5)
Defense in depth. The regex is your allowlist; the argv list is your seatbelt. When one fails, the other keeps you alive.
re.match only anchors at the start of the string, so any validator built on it accepts arbitrary trailing garbage — use re.fullmatch (or \A...\Z) and never interpolate user input into a shell string.
