Python's re.match Prefix-Only Trap: The Hostname Validator That Waves Through Shell Injection

2026-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

The Bug

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.

Key Takeaway: 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.

All newsletters