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:
co-authored by
Claude Opus 4.8
parent
4414967bdc
commit
ae3f2fc70f
+14
-1
@@ -375,6 +375,10 @@ static const char *jstr(const jval_t *v, const char *def) {
|
||||
* Config
|
||||
* ============================================================ */
|
||||
|
||||
/* Set from config in cfg_load. Off by default: a CMD packet is an unauthenticated
|
||||
* UDP datagram, so executing one must be opted into per host. */
|
||||
static bool g_allow_remote_command = false;
|
||||
|
||||
typedef struct {
|
||||
int hb_port, interval;
|
||||
char owner[256];
|
||||
@@ -429,6 +433,9 @@ static void config_load(config_t *cfg, const char *path) {
|
||||
if ((v = jget(root, "hb_port"))) cfg->hb_port = jint(v, cfg->hb_port);
|
||||
if ((v = jget(root, "interval"))) cfg->interval = jint(v, cfg->interval);
|
||||
if ((v = jget(root, "owner"))) snprintf(cfg->owner, sizeof(cfg->owner), "%s", jstr(v, ""));
|
||||
if ((v = jget(root, "allow_remote_command"))) g_allow_remote_command = jint(v, 0) != 0;
|
||||
if (g_allow_remote_command)
|
||||
LOGI("allow_remote_command is true - CMD packets from the server will be executed");
|
||||
|
||||
jval_t *plugins = jget(root, "plugins");
|
||||
|
||||
@@ -591,7 +598,13 @@ static void conn_recv(conn_t *c) {
|
||||
LOGD("ACK rtt=%.1fms", c->rtt);
|
||||
} else if (strcmp(id, "CMD") == 0) {
|
||||
const char *cmd = kv_get(&msg, "cmd");
|
||||
if (cmd) {
|
||||
if (cmd && !g_allow_remote_command) {
|
||||
LOGI("refused command (allow_remote_command is false): %s", cmd);
|
||||
kvdict_t rep; kv_clear(&rep);
|
||||
kv_set(&rep, "service", "command");
|
||||
kv_set(&rep, "msg", "Refused: allow_remote_command is false");
|
||||
conn_send(c, "HTB", &rep);
|
||||
} else if (cmd) {
|
||||
LOGI("CMD: %s", cmd);
|
||||
char out[4096] = "";
|
||||
FILE *p = popen(cmd, "r");
|
||||
|
||||
@@ -115,9 +115,14 @@ _DEFAULTS: Dict[str, Any] = {
|
||||
"hb_port": 50003,
|
||||
"interval": 10,
|
||||
"owner": None,
|
||||
"allow_remote_command": False, # Execute shell commands received in CMD packets
|
||||
"plugins": {},
|
||||
}
|
||||
|
||||
# Set from config in 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
|
||||
|
||||
|
||||
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
||||
cfg = dict(_DEFAULTS)
|
||||
@@ -870,6 +875,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
|
||||
if not cmd:
|
||||
return
|
||||
log = logging.getLogger("hbc.cmd")
|
||||
if not _allow_remote_command:
|
||||
log.warning("refused command (allow_remote_command is false): %s", cmd)
|
||||
await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"})
|
||||
return
|
||||
log.info("exec: %s", cmd)
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
@@ -1180,6 +1189,11 @@ def main(argv=None):
|
||||
|
||||
cfg = _load_config(args.configfile)
|
||||
|
||||
global _allow_remote_command
|
||||
_allow_remote_command = bool(cfg.get("allow_remote_command", False))
|
||||
if _allow_remote_command:
|
||||
logging.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
||||
|
||||
if args.daemon:
|
||||
_daemonize()
|
||||
_reconfigure_syslog(level)
|
||||
|
||||
@@ -112,9 +112,14 @@ _DEFAULTS: Dict[str, Any] = {
|
||||
"hb_port": 50003,
|
||||
"interval": 10,
|
||||
"owner": None,
|
||||
"allow_remote_command": False, # Execute shell commands received in CMD packets
|
||||
"plugins": {},
|
||||
}
|
||||
|
||||
# Set from config in 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
|
||||
|
||||
|
||||
def _load_config(path: Optional[str] = None) -> Dict[str, Any]:
|
||||
cfg = dict(_DEFAULTS)
|
||||
@@ -886,6 +891,10 @@ async def _handle_command(conn: AsyncConnection, msg: Dict[str, Any]):
|
||||
if not cmd:
|
||||
return
|
||||
log = logging.getLogger("hbc.cmd")
|
||||
if not _allow_remote_command:
|
||||
log.warning("refused command (allow_remote_command is false): %s", cmd)
|
||||
await conn.sendto({"service": "command", "msg": "Refused: allow_remote_command is false"})
|
||||
return
|
||||
log.info("exec: %s", cmd)
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
@@ -1180,6 +1189,11 @@ def main(argv=None):
|
||||
|
||||
cfg = _load_config(args.configfile)
|
||||
|
||||
global _allow_remote_command
|
||||
_allow_remote_command = bool(cfg.get("allow_remote_command", False))
|
||||
if _allow_remote_command:
|
||||
logging.warning("allow_remote_command is true — CMD packets from the server will be executed")
|
||||
|
||||
try:
|
||||
rc = asyncio.run(_async_main(args, cfg))
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Reference in New Issue
Block a user