This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new bffcc9e06 fix(ai): refuse a probed binary name the shell could parse 
as syntax (#4800)
bffcc9e06 is described below

commit bffcc9e06e8e865733d0bd675d3bfb445cfff47a
Author: Wang1rrr <[email protected]>
AuthorDate: Sat Sep 26 18:10:34 2026 +0800

    fix(ai): refuse a probed binary name the shell could parse as syntax (#4800)
    
    CliBinaryProbe interpolates its argument straight into sh -c "command -v 
<name>",
    and isAvailable(String) is public. Every call site today passes a 
compile-time
    constant (claude, qodercli, rmqctl), so nothing reaches that concatenation 
with a
    variable right now. But the invariant is a convention spread over four 
classes, and
    the first caller that forwards a configured or request-derived name turns 
it into
    command injection: "rmqctl; id" runs id, "/bin/sh" runs the shell, "-x" is 
read as
    an option to command.
    
    Validate against a bare POSIX-style identifier and fail closed, matching 
the posture
    the probe already documents - a timeout, an I/O failure and an interrupt 
all mean
    "not available", because availability gates spawning a CLI and guessing yes 
is worse
    than guessing no. Refusal happens before a ProcessBuilder exists, so a 
rejected name
    never reaches the shell, and the log line does not echo the value: this is 
the branch
    a hostile name would arrive on.
    
    No behaviour change for the constants in use. Two new cases in 
CliAgentProviderTest
    cover it: the injection shapes are refused without a process ever starting, 
and the
    names the callers pass still build the same "command -v <name>" lookup.
---
 .../ops/ai/conversation/agent/CliBinaryProbe.java  | 20 ++++++++++-
 .../studio/ops/ai/CliAgentProviderTest.java        | 39 ++++++++++++++++++++++
 2 files changed, 58 insertions(+), 1 deletion(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/CliBinaryProbe.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/CliBinaryProbe.java
index 02e23107e..e19360168 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/CliBinaryProbe.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/agent/CliBinaryProbe.java
@@ -22,6 +22,7 @@ import 
org.apache.rocketmq.studio.ops.ai.CliProcessEnvironment;
 import java.io.IOException;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
 
 /**
  * Answers "is this CLI on PATH in the runtime image?" by running {@code sh -c 
"command -v <binary>"}
@@ -49,6 +50,16 @@ public final class CliBinaryProbe {
      * not be able to stall the caller. */
     private static final long PROBE_TIMEOUT_SECONDS = 5;
 
+    /**
+     * A bare POSIX-style binary name. The probe interpolates this value into 
{@code sh -c}, so
+     * anything a shell could read as syntax — a space, a command separator, a 
substitution, a
+     * glob, a path separator — has to be refused rather than escaped: no 
quoting survives being
+     * handed to a command line somebody else assembled. Every caller today 
passes a compile-time
+     * constant ({@code claude}, {@code qodercli}, {@code rmqctl}); this is 
what keeps that true by
+     * construction instead of by convention.
+     */
+    private static final Pattern BINARY_NAME = 
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]*");
+
     private final EnvironmentApplier environmentApplier;
     private final ProcessStarter processStarter;
 
@@ -69,9 +80,16 @@ public final class CliBinaryProbe {
     /**
      * @return true when {@code command -v <binaryName>} exits 0 within the 
probe budget. A timeout,
      *     an I/O failure and an interrupt all mean "not available": 
availability is a precondition
-     *     for spawning a CLI, and guessing yes is worse than guessing no.
+     *     for spawning a CLI, and guessing yes is worse than guessing no. A 
name that is not a bare
+     *     identifier (see {@link #BINARY_NAME}) is refused the same way, 
before a process is built.
      */
     public boolean isAvailable(String binaryName) {
+        if (binaryName == null || !BINARY_NAME.matcher(binaryName).matches()) {
+            // Fail closed, and log without echoing the value: this is the 
branch a hostile name
+            // would reach, and a log line is not the place to hand it back 
unescaped.
+            log.warn("refusing to probe a binary name that is not a bare 
identifier");
+            return false;
+        }
         Process process = null;
         try {
             ProcessBuilder builder = new ProcessBuilder("sh", "-c", "command 
-v " + binaryName);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
index 932141790..6f2579ebe 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -233,4 +233,43 @@ class CliAgentProviderTest {
         assertThat(probe.isAvailable("sh")).isTrue();
         assertThat(applied).containsExactly(Map.of());
     }
+
+    @Test
+    void probeRefusesANameTheShellCouldReadAsSyntaxTest() {
+        CliBinaryProbe probe = new CliBinaryProbe(
+                (builder, providerEnvironment) -> { },
+                builder -> {
+                    throw new AssertionError("a refused name must never reach 
the shell");
+                });
+
+        // Every case is a bare name plus something "sh -c" would parse, which 
is exactly what
+        // interpolating into "command -v " turns into a second command, an 
option or a path.
+        for (String name : List.of("rmqctl; id", "rmqctl && id", "rmqctl | 
id", "claude$(id)",
+                "claude`id`", "rmqctl\nid", "/bin/sh", "-x", " ", "")) {
+            assertThat(probe.isAvailable(name)).as("name=[%s]", 
name).isFalse();
+        }
+        assertThat(probe.isAvailable(null)).isFalse();
+    }
+
+    @Test
+    void probeStillAcceptsTheBareNamesItsCallersPassTest() throws 
InterruptedException {
+        AtomicReference<List<String>> probed = new AtomicReference<>();
+        Process process = mock(Process.class);
+        when(process.waitFor(anyLong(), 
eq(java.util.concurrent.TimeUnit.SECONDS))).thenReturn(true);
+        when(process.exitValue()).thenReturn(0);
+        CliBinaryProbe probe = new CliBinaryProbe(
+                (builder, providerEnvironment) -> { },
+                builder -> {
+                    probed.set(List.copyOf(builder.command()));
+                    return process;
+                });
+
+        // The constants the callers pass today, plus the shapes the 
identifier pattern allows.
+        assertThat(probe.isAvailable("rmqctl")).isTrue();
+        assertThat(probed.get()).containsExactly("sh", "-c", "command -v 
rmqctl");
+        assertThat(probe.isAvailable("claude")).isTrue();
+        assertThat(probe.isAvailable("qodercli")).isTrue();
+        assertThat(probe.isAvailable("definitely-not-on-path-9f3a")).isTrue();
+        assertThat(probe.isAvailable("rmqctl.beta_2+x")).isTrue();
+    }
 }

Reply via email to