feat: gate remote command execution behind allow_remote_command

CMD packets arrive as unauthenticated UDP datagrams, yet every hbc client
executed the shell command they carry without any opt-in. Add an
allow_remote_command config key, default false: when off, the command is
logged and refused with "Refused: allow_remote_command is false" (visible in
the server event log under the command service), and subprocess is never
reached. When on, the client warns at startup that it will execute CMD
packets.

Applied to all four clients that handle CMD — hbc, hbc_windows.py,
hbc_mini.py, and the C hbc_mini — since gating only one leaves the others
wide open. The C client reads the same key from ~/.hbc.json and needs a
rebuild to pick it up. UPD (self-update) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-23 13:10:41 -07:00
co-authored by Claude Opus 4.8
parent 4414967bdc
commit ae3f2fc70f
7 changed files with 195 additions and 8 deletions
+22 -6
View File
@@ -37,6 +37,10 @@ dorestart = False
shutdown_event: Optional[asyncio.Event] = None
active_tasks: List[asyncio.Task] = []
# Set from config in async_main. Off by default: a CMD packet is an unauthenticated
# UDP datagram, so executing one must be opted into per host.
allow_remote_command = False
class AsyncConnection:
"""Async UDP connection to a heartbeat server."""
@@ -183,16 +187,25 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
async def handle_command(conn: AsyncConnection, msg: dict):
"""Execute a command received from server."""
"""Execute a command received from server, if allow_remote_command is set."""
import subprocess
cmd = msg.get("cmd", "")
if not cmd:
return
logger = logging.getLogger("hbc.command")
if not allow_remote_command:
logger.warning(f"Refused command (allow_remote_command is false): {cmd}")
await conn.sendto({
"service": "command",
"msg": "Refused: allow_remote_command is false",
})
return
logger.info(f"Executing command: {cmd}")
try:
result = subprocess.check_output(
cmd, shell=True, stderr=subprocess.STDOUT, timeout=30
@@ -501,11 +514,12 @@ async def cleanup(connections: List[AsyncConnection]):
async def async_main(args, config):
"""Async main function."""
global running, shutdown_event, active_tasks, send_shutdown
global running, shutdown_event, active_tasks, send_shutdown, allow_remote_command
# Create shutdown event
shutdown_event = asyncio.Event()
active_tasks = []
allow_remote_command = bool(config.get("allow_remote_command", False))
logger = logging.getLogger("hbc.main")
@@ -519,6 +533,8 @@ async def async_main(args, config):
interval = config.get("interval", INTERVAL)
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
if allow_remote_command:
logger.warning("allow_remote_command is true — CMD packets from the server will be executed")
af_filter = (socket.AF_INET if getattr(args, "ipv4_only", False)
else socket.AF_INET6 if getattr(args, "ipv6_only", False)